mirror of
https://github.com/advplyr/audiobookshelf-app.git
synced 2026-08-29 06:37:16 +02:00
Add support for background download and resumption
This commit is contained in:
@@ -6,6 +6,7 @@
|
|||||||
|
|
||||||
<!-- Permissions -->
|
<!-- Permissions -->
|
||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||||
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
|
||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
|
||||||
<uses-permission android:name="android.permission.INTERNET" />
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
<uses-permission
|
<uses-permission
|
||||||
@@ -98,6 +99,12 @@
|
|||||||
</intent-filter>
|
</intent-filter>
|
||||||
</service>
|
</service>
|
||||||
|
|
||||||
|
<service
|
||||||
|
android:name=".services.DownloadService"
|
||||||
|
android:enabled="true"
|
||||||
|
android:exported="false"
|
||||||
|
android:foregroundServiceType="dataSync" />
|
||||||
|
|
||||||
<provider
|
<provider
|
||||||
android:name="androidx.core.content.FileProvider"
|
android:name="androidx.core.content.FileProvider"
|
||||||
android:authorities="${applicationId}.fileprovider"
|
android:authorities="${applicationId}.fileprovider"
|
||||||
|
|||||||
+308
-127
@@ -1,9 +1,10 @@
|
|||||||
package com.audiobookshelf.app.managers
|
package com.audiobookshelf.app.managers
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
|
import android.os.StatFs
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import androidx.documentfile.provider.DocumentFile
|
import androidx.documentfile.provider.DocumentFile
|
||||||
import com.audiobookshelf.app.MainActivity
|
|
||||||
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
|
||||||
@@ -14,6 +15,7 @@ import com.getcapacitor.JSObject
|
|||||||
import java.io.File
|
import java.io.File
|
||||||
import java.io.FileInputStream
|
import java.io.FileInputStream
|
||||||
import java.util.concurrent.ConcurrentHashMap
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
|
import kotlin.math.max
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.SupervisorJob
|
import kotlinx.coroutines.SupervisorJob
|
||||||
@@ -22,27 +24,36 @@ import kotlinx.coroutines.delay
|
|||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import okhttp3.Call
|
import okhttp3.Call
|
||||||
|
|
||||||
/** Owns the Android download queue and writes all bytes to app-owned staging files. */
|
/**
|
||||||
|
* Process-owned Android download queue. Every network write goes through app-owned staging and is
|
||||||
|
* admitted only after reserving enough space for the complete operation.
|
||||||
|
*/
|
||||||
class DownloadItemManager(
|
class DownloadItemManager(
|
||||||
private val folderScanner: FolderScanner,
|
private val folderScanner: FolderScanner,
|
||||||
private val mainActivity: MainActivity,
|
private val context: Context,
|
||||||
private val clientEventEmitter: DownloadEventEmitter
|
private var clientEventEmitter: DownloadEventEmitter
|
||||||
) {
|
) {
|
||||||
private val tag = "DownloadItemManager"
|
private val tag = "DownloadItemManager"
|
||||||
private val maxSimultaneousDownloads = 3
|
|
||||||
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, Call>()
|
||||||
|
/** DocumentsProvider does not make concurrent createDirectory/findFile calls atomic. */
|
||||||
|
private val safFolderLocks = ConcurrentHashMap<String, Any>()
|
||||||
|
private val reservations = mutableMapOf<String, Long>()
|
||||||
|
private val lastPersistTime = mutableMapOf<String, Long>()
|
||||||
private var watcherRunning = false
|
private var watcherRunning = false
|
||||||
private val jacksonMapper =
|
private val jacksonMapper =
|
||||||
jacksonObjectMapper().enable(JsonReadFeature.ALLOW_UNESCAPED_CONTROL_CHARS.mappedFeature())
|
jacksonObjectMapper().enable(JsonReadFeature.ALLOW_UNESCAPED_CONTROL_CHARS.mappedFeature())
|
||||||
|
|
||||||
var downloadItemQueue: MutableList<DownloadItem> = mutableListOf()
|
var downloadItemQueue: MutableList<DownloadItem> = mutableListOf()
|
||||||
|
private set
|
||||||
var currentDownloadItemParts: MutableList<DownloadItemPart> = mutableListOf()
|
var currentDownloadItemParts: MutableList<DownloadItemPart> = mutableListOf()
|
||||||
|
private set
|
||||||
|
|
||||||
interface DownloadEventEmitter {
|
interface DownloadEventEmitter {
|
||||||
fun onDownloadItem(downloadItem: DownloadItem)
|
fun onDownloadItem(downloadItem: DownloadItem)
|
||||||
fun onDownloadItemPartUpdate(downloadItemPart: DownloadItemPart)
|
fun onDownloadItemPartUpdate(downloadItemPart: DownloadItemPart)
|
||||||
fun onDownloadItemComplete(jsobj: JSObject)
|
fun onDownloadItemComplete(jsobj: JSObject)
|
||||||
|
fun onQueueChanged(hasWork: Boolean)
|
||||||
}
|
}
|
||||||
|
|
||||||
interface InternalProgressCallback {
|
interface InternalProgressCallback {
|
||||||
@@ -55,49 +66,129 @@ class DownloadItemManager(
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Synchronized
|
@Synchronized
|
||||||
fun addDownloadItem(downloadItem: DownloadItem) {
|
fun setEventEmitter(eventEmitter: DownloadEventEmitter) {
|
||||||
DeviceManager.dbManager.saveDownloadItem(downloadItem)
|
clientEventEmitter = eventEmitter
|
||||||
downloadItemQueue.add(downloadItem)
|
downloadItemQueue.forEach(clientEventEmitter::onDownloadItem)
|
||||||
clientEventEmitter.onDownloadItem(downloadItem)
|
notifyQueueChanged()
|
||||||
checkUpdateDownloadQueue()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Synchronized
|
@Synchronized
|
||||||
private fun checkUpdateDownloadQueue() {
|
fun restoreQueue() {
|
||||||
for (downloadItem in downloadItemQueue.toList()) {
|
if (downloadItemQueue.isNotEmpty()) return
|
||||||
val availableSlots = maxSimultaneousDownloads - currentDownloadItemParts.size
|
DeviceManager.dbManager.getDownloadItems().forEach { item ->
|
||||||
if (availableSlots <= 0) break
|
if (item.isDownloadFinished) {
|
||||||
downloadItem.getNextDownloadItemParts(availableSlots).forEach(::startDownload)
|
downloadItemQueue.add(item)
|
||||||
|
checkDownloadItemFinished(item)
|
||||||
|
return@forEach
|
||||||
|
}
|
||||||
|
item.downloadItemParts.forEach { part ->
|
||||||
|
if (part.moved) return@forEach
|
||||||
|
part.downloadId = null
|
||||||
|
part.isMoving = false
|
||||||
|
part.failed = false
|
||||||
|
part.completed = false
|
||||||
|
part.waitingForSpace = false
|
||||||
|
part.bytesDownloaded = File(part.destinationPath).takeIf(File::exists)?.length() ?: 0L
|
||||||
|
}
|
||||||
|
downloadItemQueue.add(item)
|
||||||
|
clientEventEmitter.onDownloadItem(item)
|
||||||
}
|
}
|
||||||
if (currentDownloadItemParts.isNotEmpty()) startWatchingDownloads()
|
checkUpdateDownloadQueue()
|
||||||
|
notifyQueueChanged()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun startDownload(part: DownloadItemPart) {
|
@Synchronized
|
||||||
|
fun addDownloadItem(downloadItem: DownloadItem) {
|
||||||
|
if (downloadItemQueue.any { it.id == downloadItem.id }) return
|
||||||
|
persist(downloadItem, force = true)
|
||||||
|
downloadItemQueue.add(downloadItem)
|
||||||
|
clientEventEmitter.onDownloadItem(downloadItem)
|
||||||
|
checkUpdateDownloadQueue()
|
||||||
|
notifyQueueChanged()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Synchronized
|
||||||
|
fun retryAll() {
|
||||||
|
downloadItemQueue.forEach { item ->
|
||||||
|
item.downloadItemParts.filter { it.failed }.forEach { part ->
|
||||||
|
part.failed = false
|
||||||
|
part.completed = false
|
||||||
|
part.isMoving = false
|
||||||
|
part.downloadId = null
|
||||||
|
part.retryCount = 0
|
||||||
|
}
|
||||||
|
persist(item, force = true)
|
||||||
|
}
|
||||||
|
checkUpdateDownloadQueue()
|
||||||
|
notifyQueueChanged()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Synchronized
|
||||||
|
fun cancelAll() {
|
||||||
|
activeCalls.values.forEach(Call::cancel)
|
||||||
|
activeCalls.clear()
|
||||||
|
downloadItemQueue.forEach { item ->
|
||||||
|
item.downloadItemParts.forEach { part -> File(part.destinationPath).delete() }
|
||||||
|
DeviceManager.dbManager.removeDownloadItem(item.id)
|
||||||
|
}
|
||||||
|
currentDownloadItemParts.clear()
|
||||||
|
reservations.clear()
|
||||||
|
downloadItemQueue.clear()
|
||||||
|
notifyQueueChanged()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Synchronized
|
||||||
|
fun hasWork(): Boolean = downloadItemQueue.isNotEmpty()
|
||||||
|
|
||||||
|
@Synchronized
|
||||||
|
private fun checkUpdateDownloadQueue() {
|
||||||
|
downloadItemQueue.toList().forEach { item ->
|
||||||
|
val slots = MAX_SIMULTANEOUS_DOWNLOADS - currentDownloadItemParts.size
|
||||||
|
if (slots <= 0) return@forEach
|
||||||
|
item.getNextDownloadItemParts(slots).forEach { part ->
|
||||||
|
if (tryReserve(part)) startDownload(item, part)
|
||||||
|
else {
|
||||||
|
part.waitingForSpace = true
|
||||||
|
part.lastUpdateTime = System.currentTimeMillis()
|
||||||
|
persist(item)
|
||||||
|
clientEventEmitter.onDownloadItemPartUpdate(part)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
startWatchingDownloads()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun startDownload(item: DownloadItem, part: DownloadItemPart) {
|
||||||
val stagingFile = File(part.destinationPath)
|
val stagingFile = File(part.destinationPath)
|
||||||
stagingFile.parentFile?.mkdirs()
|
stagingFile.parentFile?.mkdirs()
|
||||||
part.downloadId = APP_MANAGED_DOWNLOAD_ID
|
part.downloadId = APP_MANAGED_DOWNLOAD_ID
|
||||||
|
part.waitingForSpace = false
|
||||||
part.lastUpdateTime = System.currentTimeMillis()
|
part.lastUpdateTime = System.currentTimeMillis()
|
||||||
currentDownloadItemParts.add(part)
|
currentDownloadItemParts.add(part)
|
||||||
val callback =
|
persist(item, force = true)
|
||||||
object : InternalProgressCallback {
|
activeCalls[part.id] =
|
||||||
|
InternalDownloadManager(stagingFile, part.fileSize, object : InternalProgressCallback {
|
||||||
override fun onProgress(totalBytesWritten: Long, progress: Long) {
|
override fun onProgress(totalBytesWritten: Long, progress: Long) {
|
||||||
synchronized(this@DownloadItemManager) {
|
synchronized(this@DownloadItemManager) {
|
||||||
|
if (part !in currentDownloadItemParts) return
|
||||||
part.bytesDownloaded = totalBytesWritten
|
part.bytesDownloaded = totalBytesWritten
|
||||||
part.progress = progress
|
part.progress = progress
|
||||||
part.lastUpdateTime = System.currentTimeMillis()
|
part.lastUpdateTime = System.currentTimeMillis()
|
||||||
|
persist(item)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onComplete(failed: Boolean) {
|
override fun onComplete(failed: Boolean) {
|
||||||
synchronized(this@DownloadItemManager) {
|
synchronized(this@DownloadItemManager) {
|
||||||
|
if (part !in currentDownloadItemParts) return
|
||||||
part.failed = failed
|
part.failed = failed
|
||||||
part.completed = true
|
part.completed = !failed
|
||||||
part.lastUpdateTime = System.currentTimeMillis()
|
part.lastUpdateTime = System.currentTimeMillis()
|
||||||
activeCalls.remove(part.id)
|
activeCalls.remove(part.id)
|
||||||
|
persist(item, force = true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}, { hasAvailableSpace(part) }).download(serverUrl(item, part))
|
||||||
activeCalls[part.id] = InternalDownloadManager(stagingFile, part.fileSize, callback).download(part.serverUrl)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Synchronized
|
@Synchronized
|
||||||
@@ -107,101 +198,223 @@ class DownloadItemManager(
|
|||||||
scope.launch {
|
scope.launch {
|
||||||
while (true) {
|
while (true) {
|
||||||
val activeParts = synchronized(this@DownloadItemManager) { currentDownloadItemParts.toList() }
|
val activeParts = synchronized(this@DownloadItemManager) { currentDownloadItemParts.toList() }
|
||||||
if (activeParts.isEmpty()) break
|
|
||||||
activeParts.forEach(::handlePartUpdate)
|
activeParts.forEach(::handlePartUpdate)
|
||||||
|
synchronized(this@DownloadItemManager) {
|
||||||
|
checkUpdateDownloadQueue()
|
||||||
|
if (downloadItemQueue.isEmpty()) {
|
||||||
|
watcherRunning = false
|
||||||
|
notifyQueueChanged()
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
}
|
||||||
delay(WATCH_INTERVAL_MS)
|
delay(WATCH_INTERVAL_MS)
|
||||||
synchronized(this@DownloadItemManager) { checkUpdateDownloadQueue() }
|
|
||||||
}
|
}
|
||||||
synchronized(this@DownloadItemManager) { watcherRunning = false }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun handlePartUpdate(part: DownloadItemPart) {
|
private fun handlePartUpdate(part: DownloadItemPart) {
|
||||||
clientEventEmitter.onDownloadItemPartUpdate(part)
|
clientEventEmitter.onDownloadItemPartUpdate(part)
|
||||||
if (!part.completed) {
|
val item = synchronized(this) { downloadItemQueue.find { it.id == part.downloadItemId } } ?: run {
|
||||||
|
removeActivePart(part)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!part.completed && !part.failed) {
|
||||||
val lastUpdate = part.lastUpdateTime ?: return
|
val lastUpdate = part.lastUpdateTime ?: return
|
||||||
if (System.currentTimeMillis() - lastUpdate > STALL_TIMEOUT_MS) {
|
if (System.currentTimeMillis() - lastUpdate > STALL_TIMEOUT_MS) {
|
||||||
Log.e(tag, "Download stalled: ${part.filename}")
|
Log.w(tag, "Download stalled: ${part.filename}")
|
||||||
activeCalls.remove(part.id)?.cancel()
|
activeCalls.remove(part.id)?.cancel()
|
||||||
synchronized(this) {
|
failOrRetry(item, part, "Download stalled")
|
||||||
part.failed = true
|
|
||||||
part.completed = true
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
val item = synchronized(this) { downloadItemQueue.find { it.id == part.downloadItemId } }
|
|
||||||
if (item == null) {
|
|
||||||
removeActivePart(part)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (part.failed) {
|
if (part.failed) {
|
||||||
removeActivePart(part)
|
failOrRetry(item, part, "Transfer failed")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (part.isInternalStorage) finalizeInternalFile(item, part) else moveDownloadedFile(item, part)
|
if (part.isInternalStorage) finalizeInternalFile(item, part) else moveDownloadedFile(item, part)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun failOrRetry(item: DownloadItem, part: DownloadItemPart, reason: String) {
|
||||||
|
removeActivePart(part)
|
||||||
|
part.retryCount += 1
|
||||||
|
releaseReservation(part)
|
||||||
|
if (part.retryCount > MAX_RETRIES) {
|
||||||
|
Log.e(tag, "$reason after $MAX_RETRIES retries: ${part.filename}")
|
||||||
|
part.failed = true
|
||||||
|
part.completed = false
|
||||||
|
part.downloadId = null
|
||||||
|
persist(item, force = true)
|
||||||
|
notifyQueueChanged()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
part.failed = false
|
||||||
|
part.completed = false
|
||||||
|
part.downloadId = null
|
||||||
|
part.isMoving = false
|
||||||
|
persist(item, force = true)
|
||||||
|
scope.launch {
|
||||||
|
delay(RETRY_BASE_DELAY_MS * (1L shl (part.retryCount - 1)))
|
||||||
|
synchronized(this@DownloadItemManager) { checkUpdateDownloadQueue() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun finalizeInternalFile(item: DownloadItem, part: DownloadItemPart) {
|
private fun finalizeInternalFile(item: DownloadItem, part: DownloadItemPart) {
|
||||||
if (part.moved || part.isMoving) return
|
if (part.moved || part.isMoving) return
|
||||||
part.isMoving = true
|
part.isMoving = true
|
||||||
val stagingFile = File(part.destinationPath)
|
val stagingFile = File(part.destinationPath)
|
||||||
val finalFile = File(part.finalDestinationPath)
|
val finalFile = File(part.finalDestinationPath)
|
||||||
finalFile.parentFile?.mkdirs()
|
finalFile.parentFile?.mkdirs()
|
||||||
if (finalFile.exists() && !finalFile.delete()) {
|
val backup = File(finalFile.parentFile, ".${finalFile.name}.abs-backup")
|
||||||
failFinalization(item, part, "Could not replace existing internal file")
|
try {
|
||||||
return
|
if (backup.exists() && !backup.delete()) throw IllegalStateException("Could not clear backup")
|
||||||
|
if (finalFile.exists() && !finalFile.renameTo(backup)) throw IllegalStateException("Could not protect existing file")
|
||||||
|
if (!stagingFile.renameTo(finalFile)) {
|
||||||
|
if (backup.exists()) backup.renameTo(finalFile)
|
||||||
|
throw IllegalStateException("Could not finalize internal staging file")
|
||||||
|
}
|
||||||
|
backup.delete()
|
||||||
|
completePart(item, part)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
part.isMoving = false
|
||||||
|
part.failed = true
|
||||||
|
failOrRetry(item, part, e.message ?: "Internal finalization failed")
|
||||||
}
|
}
|
||||||
if (!stagingFile.renameTo(finalFile)) {
|
|
||||||
failFinalization(item, part, "Could not finalize internal staging file")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
part.moved = true
|
|
||||||
part.isMoving = false
|
|
||||||
removeActivePart(part)
|
|
||||||
checkDownloadItemFinished(item)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun moveDownloadedFile(item: DownloadItem, part: DownloadItemPart) {
|
private fun moveDownloadedFile(item: DownloadItem, part: DownloadItemPart) {
|
||||||
if (part.moved || part.isMoving) return
|
if (part.moved || part.isMoving) return
|
||||||
val destinationRoot = DocumentFile.fromTreeUri(mainActivity, Uri.parse(part.localFolderUrl))
|
val root = DocumentFile.fromTreeUri(context, Uri.parse(part.localFolderUrl))
|
||||||
if (destinationRoot == null) {
|
?: return failFinalization(item, part, "Could not resolve SAF destination")
|
||||||
failFinalization(item, part, "Could not resolve SAF destination")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
part.isMoving = true
|
part.isMoving = true
|
||||||
|
persist(item, force = true)
|
||||||
scope.launch {
|
scope.launch {
|
||||||
try {
|
try {
|
||||||
val destinationFolder = getOrCreateFolder(destinationRoot, part.finalDestinationSubfolder)
|
if (!hasAvailableSpace(part)) throw IllegalStateException("Insufficient storage for SAF copy")
|
||||||
?: throw IllegalStateException("Could not create SAF destination folder")
|
val folderKey = "${root.uri}/${part.finalDestinationSubfolder}"
|
||||||
destinationFolder.findFile(part.filename)?.let { existing ->
|
val folderLock = safFolderLocks.computeIfAbsent(folderKey) { Any() }
|
||||||
if (!existing.delete()) throw IllegalStateException("Could not replace ${part.filename}")
|
val folder = synchronized(folderLock) {
|
||||||
|
getOrCreateFolder(root, part.finalDestinationSubfolder)
|
||||||
|
} ?: throw IllegalStateException("Could not create SAF destination folder")
|
||||||
|
val temporaryName = ".${part.filename}.${part.id.hashCode()}.part"
|
||||||
|
folder.findFile(temporaryName)?.delete()
|
||||||
|
val temporary = folder.createFile(mimeTypeFor(part), temporaryName)
|
||||||
|
?: throw IllegalStateException("Could not create SAF temporary file")
|
||||||
|
val staging = File(part.destinationPath)
|
||||||
|
FileInputStream(staging).use { input ->
|
||||||
|
context.contentResolver.openOutputStream(temporary.uri, "w")?.use { input.copyTo(it) }
|
||||||
|
?: throw IllegalStateException("Could not open SAF output stream")
|
||||||
}
|
}
|
||||||
val destinationFile = destinationFolder.createFile(mimeTypeFor(part), part.filename)
|
if (temporary.length() != staging.length()) throw IllegalStateException("SAF copy size mismatch")
|
||||||
?: throw IllegalStateException("Could not create ${part.filename}")
|
val existing = folder.findFile(part.filename)
|
||||||
val stagingFile = File(part.destinationPath)
|
if (existing != null && !existing.delete()) throw IllegalStateException("Could not replace existing file")
|
||||||
FileInputStream(stagingFile).use { input ->
|
if (!temporary.renameTo(part.filename)) throw IllegalStateException("Could not finalize SAF temporary file")
|
||||||
mainActivity.contentResolver.openOutputStream(destinationFile.uri, "w")?.use { output ->
|
val destination = folder.findFile(part.filename)
|
||||||
input.copyTo(output)
|
?: throw IllegalStateException("Could not reopen finalized SAF file")
|
||||||
} ?: throw IllegalStateException("Could not open SAF output stream")
|
if (destination.length() != staging.length()) throw IllegalStateException("SAF final size mismatch")
|
||||||
}
|
if (!staging.delete()) Log.w(tag, "Could not remove staging file ${staging.name}")
|
||||||
if (destinationFile.length() != stagingFile.length()) {
|
part.completedDestinationUri = destination.uri.toString()
|
||||||
destinationFile.delete()
|
completePart(item, part)
|
||||||
throw IllegalStateException("SAF copy size mismatch for ${part.filename}")
|
|
||||||
}
|
|
||||||
stagingFile.delete()
|
|
||||||
part.completedDestinationUri = destinationFile.uri.toString()
|
|
||||||
part.moved = true
|
|
||||||
part.isMoving = false
|
|
||||||
removeActivePart(part)
|
|
||||||
checkDownloadItemFinished(item)
|
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
failFinalization(item, part, "SAF copy failed: ${e.message}")
|
failFinalization(item, part, "SAF copy failed: ${e.message}")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun failFinalization(item: DownloadItem, part: DownloadItemPart, message: String) {
|
||||||
|
Log.e(tag, message)
|
||||||
|
part.isMoving = false
|
||||||
|
part.failed = true
|
||||||
|
failOrRetry(item, part, message)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun completePart(item: DownloadItem, part: DownloadItemPart) {
|
||||||
|
part.moved = true
|
||||||
|
part.completed = true
|
||||||
|
part.failed = false
|
||||||
|
part.isMoving = false
|
||||||
|
releaseReservation(part)
|
||||||
|
removeActivePart(part)
|
||||||
|
persist(item, force = true)
|
||||||
|
checkDownloadItemFinished(item)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun checkDownloadItemFinished(item: DownloadItem) {
|
||||||
|
if (!item.isDownloadFinished) return
|
||||||
|
scope.launch {
|
||||||
|
folderScanner.scanDownloadItem(item) { scanResult ->
|
||||||
|
val event = JSObject().apply {
|
||||||
|
put("libraryItemId", item.id)
|
||||||
|
put("localFolderId", item.localFolder.id)
|
||||||
|
scanResult?.localLibraryItem?.let { put("localLibraryItem", JSObject(jacksonMapper.writeValueAsString(it))) }
|
||||||
|
scanResult?.localMediaProgress?.let { put("localMediaProgress", JSObject(jacksonMapper.writeValueAsString(it))) }
|
||||||
|
}
|
||||||
|
clientEventEmitter.onDownloadItemComplete(event)
|
||||||
|
synchronized(this@DownloadItemManager) {
|
||||||
|
downloadItemQueue.remove(item)
|
||||||
|
DeviceManager.dbManager.removeDownloadItem(item.id)
|
||||||
|
notifyQueueChanged()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun tryReserve(part: DownloadItemPart): Boolean {
|
||||||
|
// Covers from older servers often omit a size. Keep unknown-length work serial and use the
|
||||||
|
// runtime low-space guard rather than leaving those queue items permanently deferred.
|
||||||
|
if (part.fileSize <= 0L && currentDownloadItemParts.any { it.fileSize <= 0L }) return false
|
||||||
|
val staging = File(part.destinationPath)
|
||||||
|
staging.parentFile?.mkdirs()
|
||||||
|
val expectedSize = if (part.fileSize > 0L) part.fileSize else UNKNOWN_PART_RESERVATION_BYTES
|
||||||
|
val remaining = (expectedSize - (staging.takeIf(File::exists)?.length() ?: 0L)).coerceAtLeast(0L)
|
||||||
|
val required = if (part.isInternalStorage) remaining else remaining + expectedSize
|
||||||
|
val key = storageKey(staging)
|
||||||
|
val fs = statFsFor(staging)
|
||||||
|
val headroom = max(MIN_FREE_SPACE_BYTES, fs.totalBytes / 20L)
|
||||||
|
val alreadyReserved = reservations.filterKeys { storageKey(File(it)) == key }.values.sum()
|
||||||
|
if (fs.availableBytes - alreadyReserved < required + headroom) return false
|
||||||
|
reservations[part.destinationPath] = required
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun hasAvailableSpace(part: DownloadItemPart): Boolean {
|
||||||
|
val staging = File(part.destinationPath)
|
||||||
|
val fs = statFsFor(staging)
|
||||||
|
return fs.availableBytes >= max(MIN_FREE_SPACE_BYTES, fs.totalBytes / 20L)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun statFsFor(staging: File): StatFs {
|
||||||
|
var directory = staging.parentFile ?: context.filesDir
|
||||||
|
directory.mkdirs()
|
||||||
|
while (!directory.exists()) directory = directory.parentFile ?: context.filesDir
|
||||||
|
return StatFs(directory.absolutePath)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun storageKey(file: File): String =
|
||||||
|
if (file.absolutePath.startsWith(context.filesDir.absolutePath)) "internal" else "external"
|
||||||
|
|
||||||
|
private fun releaseReservation(part: DownloadItemPart) { reservations.remove(part.destinationPath) }
|
||||||
|
|
||||||
|
@Synchronized
|
||||||
|
private fun removeActivePart(part: DownloadItemPart) {
|
||||||
|
activeCalls.remove(part.id)
|
||||||
|
currentDownloadItemParts.remove(part)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun persist(item: DownloadItem, force: Boolean = false) {
|
||||||
|
val now = System.currentTimeMillis()
|
||||||
|
if (!force && now - (lastPersistTime[item.id] ?: 0L) < PERSIST_INTERVAL_MS) return
|
||||||
|
lastPersistTime[item.id] = now
|
||||||
|
DeviceManager.dbManager.saveDownloadItem(item)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun notifyQueueChanged() { clientEventEmitter.onQueueChanged(downloadItemQueue.isNotEmpty()) }
|
||||||
|
|
||||||
|
fun destroy() {
|
||||||
|
activeCalls.values.forEach(Call::cancel)
|
||||||
|
activeCalls.clear()
|
||||||
|
scope.cancel()
|
||||||
|
}
|
||||||
|
|
||||||
private fun getOrCreateFolder(root: DocumentFile, relativePath: String): DocumentFile? {
|
private fun getOrCreateFolder(root: DocumentFile, relativePath: String): DocumentFile? {
|
||||||
var current = root
|
var current = root
|
||||||
relativePath.split('/').filter { it.isNotBlank() }.forEach { segment ->
|
relativePath.split('/').filter { it.isNotBlank() }.forEach { segment ->
|
||||||
@@ -211,62 +424,30 @@ class DownloadItemManager(
|
|||||||
return current
|
return current
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun mimeTypeFor(part: DownloadItemPart): String {
|
private fun mimeTypeFor(part: DownloadItemPart): String =
|
||||||
return part.audioTrack?.mimeType
|
part.audioTrack?.mimeType ?: when (part.ebookFile?.ebookFormat?.lowercase()) {
|
||||||
?: when (part.ebookFile?.ebookFormat?.lowercase()) {
|
"epub" -> "application/epub+zip"
|
||||||
"epub" -> "application/epub+zip"
|
"pdf" -> "application/pdf"
|
||||||
"pdf" -> "application/pdf"
|
else -> "image/jpeg"
|
||||||
else -> "image/jpeg"
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun failFinalization(item: DownloadItem, part: DownloadItemPart, message: String) {
|
private fun serverUrl(item: DownloadItem, part: DownloadItemPart): String {
|
||||||
Log.e(tag, message)
|
val token = DeviceManager.deviceData.serverConnectionConfigs
|
||||||
part.failed = true
|
.find { it.id == item.serverConnectionConfigId }?.token ?: DeviceManager.token
|
||||||
part.isMoving = false
|
var url = "${item.serverAddress}${part.serverPath}?token=$token"
|
||||||
part.completed = true
|
if (part.serverPath.endsWith("/cover")) url += "&raw=1"
|
||||||
removeActivePart(part)
|
return url
|
||||||
}
|
|
||||||
|
|
||||||
@Synchronized
|
|
||||||
private fun removeActivePart(part: DownloadItemPart) {
|
|
||||||
activeCalls.remove(part.id)
|
|
||||||
currentDownloadItemParts.remove(part)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun checkDownloadItemFinished(downloadItem: DownloadItem) {
|
|
||||||
if (!downloadItem.isDownloadFinished) return
|
|
||||||
scope.launch {
|
|
||||||
folderScanner.scanDownloadItem(downloadItem) { scanResult ->
|
|
||||||
val event =
|
|
||||||
JSObject().apply {
|
|
||||||
put("libraryItemId", downloadItem.id)
|
|
||||||
put("localFolderId", downloadItem.localFolder.id)
|
|
||||||
scanResult?.localLibraryItem?.let {
|
|
||||||
put("localLibraryItem", JSObject(jacksonMapper.writeValueAsString(it)))
|
|
||||||
}
|
|
||||||
scanResult?.localMediaProgress?.let {
|
|
||||||
put("localMediaProgress", JSObject(jacksonMapper.writeValueAsString(it)))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
clientEventEmitter.onDownloadItemComplete(event)
|
|
||||||
synchronized(this@DownloadItemManager) {
|
|
||||||
downloadItemQueue.remove(downloadItem)
|
|
||||||
DeviceManager.dbManager.removeDownloadItem(downloadItem.id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun destroy() {
|
|
||||||
activeCalls.values.forEach(Call::cancel)
|
|
||||||
activeCalls.clear()
|
|
||||||
scope.cancel()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private companion object {
|
private companion object {
|
||||||
const val APP_MANAGED_DOWNLOAD_ID = -1L
|
const val APP_MANAGED_DOWNLOAD_ID = -1L
|
||||||
const val WATCH_INTERVAL_MS = 500L
|
const val MAX_SIMULTANEOUS_DOWNLOADS = 3
|
||||||
|
const val WATCH_INTERVAL_MS = 1_000L
|
||||||
const val STALL_TIMEOUT_MS = 60_000L
|
const val STALL_TIMEOUT_MS = 60_000L
|
||||||
|
const val RETRY_BASE_DELAY_MS = 5_000L
|
||||||
|
const val MAX_RETRIES = 5
|
||||||
|
const val PERSIST_INTERVAL_MS = 2_000L
|
||||||
|
const val MIN_FREE_SPACE_BYTES = 100L * 1024L * 1024L
|
||||||
|
const val UNKNOWN_PART_RESERVATION_BYTES = 100L * 1024L * 1024L
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+18
-9
@@ -15,16 +15,10 @@ import okhttp3.Response
|
|||||||
class InternalDownloadManager(
|
class InternalDownloadManager(
|
||||||
private val destinationFile: File,
|
private val destinationFile: File,
|
||||||
private val expectedSize: Long,
|
private val expectedSize: Long,
|
||||||
private val progressCallback: DownloadItemManager.InternalProgressCallback
|
private val progressCallback: DownloadItemManager.InternalProgressCallback,
|
||||||
|
private val hasAvailableSpace: () -> Boolean
|
||||||
) {
|
) {
|
||||||
private val tag = "InternalDownloadManager"
|
private val tag = "InternalDownloadManager"
|
||||||
private val client =
|
|
||||||
OkHttpClient.Builder()
|
|
||||||
.connectTimeout(30, TimeUnit.SECONDS)
|
|
||||||
.readTimeout(60, TimeUnit.SECONDS)
|
|
||||||
.writeTimeout(60, TimeUnit.SECONDS)
|
|
||||||
.build()
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns the active call so the queue can cancel a stalled transfer. A partial staging file is
|
* Returns the active call so the queue can cancel a stalled transfer. A partial staging file is
|
||||||
* retained only when the server proves that it honoured a subsequent range request.
|
* retained only when the server proves that it honoured a subsequent range request.
|
||||||
@@ -51,6 +45,11 @@ class InternalDownloadManager(
|
|||||||
override fun onResponse(call: Call, response: Response) {
|
override fun onResponse(call: Call, response: Response) {
|
||||||
response.use {
|
response.use {
|
||||||
try {
|
try {
|
||||||
|
if (response.code == 416 && expectedSize > 0L && existingBytes == expectedSize) {
|
||||||
|
progressCallback.onProgress(existingBytes, 100L)
|
||||||
|
progressCallback.onComplete(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
val append = existingBytes > 0L && response.code == 206 && hasExpectedRange(response, existingBytes)
|
val append = existingBytes > 0L && response.code == 206 && hasExpectedRange(response, existingBytes)
|
||||||
if (existingBytes > 0L && !append && response.code != 200) {
|
if (existingBytes > 0L && !append && response.code != 200) {
|
||||||
Log.e(tag, "Invalid resume response ${response.code} for offset $existingBytes")
|
Log.e(tag, "Invalid resume response ${response.code} for offset $existingBytes")
|
||||||
@@ -77,6 +76,7 @@ class InternalDownloadManager(
|
|||||||
while (true) {
|
while (true) {
|
||||||
val read = input.read(buffer)
|
val read = input.read(buffer)
|
||||||
if (read < 0) break
|
if (read < 0) break
|
||||||
|
if (!hasAvailableSpace()) throw IOException("Download paused to preserve free storage")
|
||||||
output.write(buffer, 0, read)
|
output.write(buffer, 0, read)
|
||||||
totalBytes += read
|
totalBytes += read
|
||||||
val progress = if (totalLength > 0L) (totalBytes * 100L) / totalLength else 0L
|
val progress = if (totalLength > 0L) (totalBytes * 100L) / totalLength else 0L
|
||||||
@@ -104,10 +104,19 @@ class InternalDownloadManager(
|
|||||||
|
|
||||||
private fun hasExpectedRange(response: Response, offset: Long): Boolean {
|
private fun hasExpectedRange(response: Response, offset: Long): Boolean {
|
||||||
val range = response.header("Content-Range") ?: return false
|
val range = response.header("Content-Range") ?: return false
|
||||||
return range.startsWith("bytes $offset-")
|
val match = CONTENT_RANGE.matchEntire(range) ?: return false
|
||||||
|
return match.groupValues[1].toLongOrNull() == offset &&
|
||||||
|
match.groupValues[2].toLongOrNull()?.let { it >= offset } == true
|
||||||
}
|
}
|
||||||
|
|
||||||
private companion object {
|
private companion object {
|
||||||
const val CHUNK_SIZE = 8 * 1024
|
const val CHUNK_SIZE = 8 * 1024
|
||||||
|
val CONTENT_RANGE = Regex("bytes (\\d+)-(\\d+)/(?:\\d+|\\*)")
|
||||||
|
val client =
|
||||||
|
OkHttpClient.Builder()
|
||||||
|
.connectTimeout(30, TimeUnit.SECONDS)
|
||||||
|
.readTimeout(60, TimeUnit.SECONDS)
|
||||||
|
.writeTimeout(60, TimeUnit.SECONDS)
|
||||||
|
.build()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ data class DownloadItem(
|
|||||||
if (limit == 0) return itemParts
|
if (limit == 0) return itemParts
|
||||||
|
|
||||||
for (it in downloadItemParts) {
|
for (it in downloadItemParts) {
|
||||||
if (!it.completed && it.downloadId == null) {
|
if (!it.completed && !it.failed && it.downloadId == null) {
|
||||||
itemParts.add(it)
|
itemParts.add(it)
|
||||||
if (itemParts.size >= limit) break
|
if (itemParts.size >= limit) break
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ data class DownloadItemPart(
|
|||||||
val filename: String,
|
val filename: String,
|
||||||
val fileSize: Long,
|
val fileSize: Long,
|
||||||
/** App-owned staging location. This is intentionally a String so it survives process storage. */
|
/** App-owned staging location. This is intentionally a String so it survives process storage. */
|
||||||
val destinationPath: String,
|
@JsonIgnore val destinationPath: String,
|
||||||
val finalDestinationPath:String,
|
val finalDestinationPath:String,
|
||||||
val serverPath: String,
|
val serverPath: String,
|
||||||
val localFolderName: String,
|
val localFolderName: String,
|
||||||
@@ -33,12 +33,16 @@ data class DownloadItemPart(
|
|||||||
@JsonIgnore val destinationUri: Uri,
|
@JsonIgnore val destinationUri: Uri,
|
||||||
@JsonIgnore val finalDestinationUri: Uri,
|
@JsonIgnore val finalDestinationUri: Uri,
|
||||||
/** Final SAF document returned by the provider after a successful move. */
|
/** Final SAF document returned by the provider after a successful move. */
|
||||||
|
/** Persisted Android-only SAF URI used to reopen a completed document after process recovery. */
|
||||||
@JsonIgnore var completedDestinationUri: String?,
|
@JsonIgnore var completedDestinationUri: String?,
|
||||||
val finalDestinationSubfolder: String,
|
val finalDestinationSubfolder: String,
|
||||||
var downloadId: Long?,
|
var downloadId: Long?,
|
||||||
var lastUpdateTime: Long?,
|
@JsonIgnore var lastUpdateTime: Long?,
|
||||||
var progress: Long,
|
var progress: Long,
|
||||||
var bytesDownloaded: Long
|
var bytesDownloaded: Long,
|
||||||
|
/** Android queue state; hidden from the shared Capacitor download-part payload. */
|
||||||
|
@JsonIgnore var retryCount: Int = 0,
|
||||||
|
@JsonIgnore var waitingForSpace: Boolean = false
|
||||||
) {
|
) {
|
||||||
companion object {
|
companion object {
|
||||||
fun make(downloadItemId:String, filename:String, fileSize: Long, destinationFile: File, finalDestinationFile: File, subfolder:String, serverPath:String, localFolder: LocalFolder, ebookFile: EBookFile?, audioTrack: AudioTrack?, episode: PodcastEpisode?) :DownloadItemPart {
|
fun make(downloadItemId:String, filename:String, fileSize: Long, destinationFile: File, finalDestinationFile: File, subfolder:String, serverPath:String, localFolder: LocalFolder, ebookFile: EBookFile?, audioTrack: AudioTrack?, episode: PodcastEpisode?) :DownloadItemPart {
|
||||||
@@ -87,6 +91,11 @@ data class DownloadItemPart(
|
|||||||
val isInternalStorage get() = localFolderId.startsWith("internal-")
|
val isInternalStorage get() = localFolderId.startsWith("internal-")
|
||||||
|
|
||||||
@get:JsonIgnore
|
@get:JsonIgnore
|
||||||
val serverUrl get() = uri.toString()
|
val serverUrl: String
|
||||||
|
get() {
|
||||||
|
var url = "${DeviceManager.serverAddress}${serverPath}?token=${DeviceManager.token}"
|
||||||
|
if (serverPath.endsWith("/cover")) url += "&raw=1"
|
||||||
|
return url
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import com.audiobookshelf.app.models.DownloadItem
|
|||||||
import com.audiobookshelf.app.models.DownloadItemPart
|
import com.audiobookshelf.app.models.DownloadItemPart
|
||||||
import com.audiobookshelf.app.server.ApiHandler
|
import com.audiobookshelf.app.server.ApiHandler
|
||||||
import com.audiobookshelf.app.managers.DownloadItemManager
|
import com.audiobookshelf.app.managers.DownloadItemManager
|
||||||
|
import com.audiobookshelf.app.services.DownloadServiceHost
|
||||||
import com.fasterxml.jackson.core.json.JsonReadFeature
|
import com.fasterxml.jackson.core.json.JsonReadFeature
|
||||||
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
||||||
import com.getcapacitor.JSObject
|
import com.getcapacitor.JSObject
|
||||||
@@ -39,20 +40,36 @@ class AbsDownloader : Plugin() {
|
|||||||
override fun onDownloadItemComplete(jsobj:JSObject) {
|
override fun onDownloadItemComplete(jsobj:JSObject) {
|
||||||
notifyListeners("onItemDownloadComplete", jsobj)
|
notifyListeners("onItemDownloadComplete", jsobj)
|
||||||
}
|
}
|
||||||
|
override fun onQueueChanged(hasWork: Boolean) = Unit
|
||||||
})
|
})
|
||||||
|
|
||||||
override fun load() {
|
override fun load() {
|
||||||
mainActivity = (activity as MainActivity)
|
mainActivity = (activity as MainActivity)
|
||||||
folderScanner = FolderScanner(mainActivity)
|
folderScanner = FolderScanner(mainActivity)
|
||||||
apiHandler = ApiHandler(mainActivity)
|
apiHandler = ApiHandler(mainActivity)
|
||||||
downloadItemManager = DownloadItemManager(folderScanner, mainActivity, clientEventEmitter)
|
downloadItemManager = DownloadServiceHost.ensure(mainActivity)
|
||||||
|
DownloadServiceHost.attachBridge(mainActivity, clientEventEmitter)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun handleOnDestroy() {
|
override fun handleOnDestroy() {
|
||||||
if (::downloadItemManager.isInitialized) downloadItemManager.destroy()
|
DownloadServiceHost.detachBridge()
|
||||||
super.handleOnDestroy()
|
super.handleOnDestroy()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Queue restoration happens before the WebView mounts. Replay its parent items when Vue registers
|
||||||
|
* the listener so subsequent part updates always have a matching store entry.
|
||||||
|
*/
|
||||||
|
@PluginMethod(returnType = PluginMethod.RETURN_NONE)
|
||||||
|
override fun addListener(call: PluginCall) {
|
||||||
|
super.addListener(call)
|
||||||
|
if (call.getString("eventName") == "onDownloadItem" && ::downloadItemManager.isInitialized) {
|
||||||
|
downloadItemManager.downloadItemQueue.forEach { item ->
|
||||||
|
notifyListeners("onDownloadItem", JSObject(jacksonMapper.writeValueAsString(item)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@PluginMethod
|
@PluginMethod
|
||||||
fun downloadLibraryItem(call: PluginCall) {
|
fun downloadLibraryItem(call: PluginCall) {
|
||||||
val libraryItemId = call.data.getString("libraryItemId").toString()
|
val libraryItemId = call.data.getString("libraryItemId").toString()
|
||||||
@@ -163,11 +180,6 @@ class AbsDownloader : Plugin() {
|
|||||||
val finalDestinationFile = File("$itemFolderPath/$destinationFilename")
|
val finalDestinationFile = File("$itemFolderPath/$destinationFilename")
|
||||||
val destinationFile = File("$tempFolderPath/$destinationFilename.part")
|
val destinationFile = File("$tempFolderPath/$destinationFilename.part")
|
||||||
|
|
||||||
if (finalDestinationFile.exists()) {
|
|
||||||
Log.d(tag, "ebook file already exists, removing it from ${finalDestinationFile.absolutePath}")
|
|
||||||
finalDestinationFile.delete()
|
|
||||||
}
|
|
||||||
|
|
||||||
val downloadItemPart = DownloadItemPart.make(downloadItem.id, destinationFilename, fileSize, destinationFile,finalDestinationFile,itemSubfolder,serverPath,localFolder,ebookFile,null,null)
|
val downloadItemPart = DownloadItemPart.make(downloadItem.id, destinationFilename, fileSize, destinationFile,finalDestinationFile,itemSubfolder,serverPath,localFolder,ebookFile,null,null)
|
||||||
downloadItem.downloadItemParts.add(downloadItemPart)
|
downloadItem.downloadItemParts.add(downloadItemPart)
|
||||||
}
|
}
|
||||||
@@ -187,11 +199,6 @@ class AbsDownloader : Plugin() {
|
|||||||
val finalDestinationFile = File("$itemFolderPath/$destinationFilename")
|
val finalDestinationFile = File("$itemFolderPath/$destinationFilename")
|
||||||
val destinationFile = File("$tempFolderPath/$destinationFilename.part")
|
val destinationFile = File("$tempFolderPath/$destinationFilename.part")
|
||||||
|
|
||||||
if (finalDestinationFile.exists()) {
|
|
||||||
Log.d(tag, "Audio file already exists, removing it from ${finalDestinationFile.absolutePath}")
|
|
||||||
finalDestinationFile.delete()
|
|
||||||
}
|
|
||||||
|
|
||||||
val downloadItemPart = DownloadItemPart.make(downloadItem.id, destinationFilename, fileSize, destinationFile,finalDestinationFile,itemSubfolder,serverPath,localFolder,null,audioTrack,null)
|
val downloadItemPart = DownloadItemPart.make(downloadItem.id, destinationFilename, fileSize, destinationFile,finalDestinationFile,itemSubfolder,serverPath,localFolder,null,audioTrack,null)
|
||||||
downloadItem.downloadItemParts.add(downloadItemPart)
|
downloadItem.downloadItemParts.add(downloadItemPart)
|
||||||
}
|
}
|
||||||
@@ -207,16 +214,11 @@ class AbsDownloader : Plugin() {
|
|||||||
val destinationFile = File("$tempFolderPath/$destinationFilename.part")
|
val destinationFile = File("$tempFolderPath/$destinationFilename.part")
|
||||||
val finalDestinationFile = File("$itemFolderPath/$destinationFilename")
|
val finalDestinationFile = File("$itemFolderPath/$destinationFilename")
|
||||||
|
|
||||||
if (finalDestinationFile.exists()) {
|
|
||||||
Log.d(tag, "Cover already exists, removing it from ${finalDestinationFile.absolutePath}")
|
|
||||||
finalDestinationFile.delete()
|
|
||||||
}
|
|
||||||
|
|
||||||
val downloadItemPart = DownloadItemPart.make(downloadItem.id, destinationFilename, coverFileSize, destinationFile,finalDestinationFile,itemSubfolder,serverPath,localFolder,null,null,null)
|
val downloadItemPart = DownloadItemPart.make(downloadItem.id, destinationFilename, coverFileSize, destinationFile,finalDestinationFile,itemSubfolder,serverPath,localFolder,null,null,null)
|
||||||
downloadItem.downloadItemParts.add(downloadItemPart)
|
downloadItem.downloadItemParts.add(downloadItemPart)
|
||||||
}
|
}
|
||||||
|
|
||||||
downloadItemManager.addDownloadItem(downloadItem)
|
DownloadServiceHost.enqueue(mainActivity, downloadItem)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Podcast episode download
|
// Podcast episode download
|
||||||
@@ -237,11 +239,6 @@ class AbsDownloader : Plugin() {
|
|||||||
|
|
||||||
var destinationFile = File("$tempFolderPath/$destinationFilename.part")
|
var destinationFile = File("$tempFolderPath/$destinationFilename.part")
|
||||||
var finalDestinationFile = File("$itemFolderPath/$destinationFilename")
|
var finalDestinationFile = File("$itemFolderPath/$destinationFilename")
|
||||||
if (finalDestinationFile.exists()) {
|
|
||||||
Log.d(tag, "Audio file already exists, removing it from ${finalDestinationFile.absolutePath}")
|
|
||||||
finalDestinationFile.delete()
|
|
||||||
}
|
|
||||||
|
|
||||||
var downloadItemPart = DownloadItemPart.make(downloadItem.id, destinationFilename,fileSize, destinationFile,finalDestinationFile,podcastTitle,serverPath,localFolder,null,audioTrack,episode)
|
var downloadItemPart = DownloadItemPart.make(downloadItem.id, destinationFilename,fileSize, destinationFile,finalDestinationFile,podcastTitle,serverPath,localFolder,null,audioTrack,episode)
|
||||||
downloadItem.downloadItemParts.add(downloadItemPart)
|
downloadItem.downloadItemParts.add(downloadItemPart)
|
||||||
|
|
||||||
@@ -255,15 +252,11 @@ class AbsDownloader : Plugin() {
|
|||||||
destinationFile = File("$tempFolderPath/$destinationFilename.part")
|
destinationFile = File("$tempFolderPath/$destinationFilename.part")
|
||||||
finalDestinationFile = File("$itemFolderPath/$destinationFilename")
|
finalDestinationFile = File("$itemFolderPath/$destinationFilename")
|
||||||
|
|
||||||
if (finalDestinationFile.exists()) {
|
downloadItemPart = DownloadItemPart.make(downloadItem.id, destinationFilename,coverFileSize,destinationFile,finalDestinationFile,podcastTitle,serverPath,localFolder,null,null,null)
|
||||||
Log.d(tag, "Podcast cover already exists - not downloading cover again")
|
downloadItem.downloadItemParts.add(downloadItemPart)
|
||||||
} else {
|
|
||||||
downloadItemPart = DownloadItemPart.make(downloadItem.id, destinationFilename,coverFileSize,destinationFile,finalDestinationFile,podcastTitle,serverPath,localFolder,null,null,null)
|
|
||||||
downloadItem.downloadItemParts.add(downloadItemPart)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
downloadItemManager.addDownloadItem(downloadItem)
|
DownloadServiceHost.enqueue(mainActivity, downloadItem)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
package com.audiobookshelf.app.services
|
||||||
|
|
||||||
|
import android.app.Notification
|
||||||
|
import android.app.NotificationChannel
|
||||||
|
import android.app.NotificationManager
|
||||||
|
import android.app.PendingIntent
|
||||||
|
import android.app.Service
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.os.IBinder
|
||||||
|
import androidx.core.app.NotificationCompat
|
||||||
|
import com.audiobookshelf.app.R
|
||||||
|
import com.audiobookshelf.app.models.DownloadItemPart
|
||||||
|
|
||||||
|
/** Android-owned foreground lifecycle for transfers that must outlive the WebView and Activity. */
|
||||||
|
class DownloadService : Service() {
|
||||||
|
private var lastPart: DownloadItemPart? = null
|
||||||
|
|
||||||
|
override fun onCreate() {
|
||||||
|
super.onCreate()
|
||||||
|
createChannel()
|
||||||
|
startForeground(NOTIFICATION_ID, notification("Preparing downloads"))
|
||||||
|
DownloadServiceHost.attachService(this)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||||
|
when (intent?.action) {
|
||||||
|
ACTION_CANCEL -> DownloadServiceHost.cancelAll(this)
|
||||||
|
ACTION_RETRY -> DownloadServiceHost.retryAll(this)
|
||||||
|
else -> DownloadServiceHost.ensure(this)
|
||||||
|
}
|
||||||
|
return START_STICKY
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDestroy() {
|
||||||
|
DownloadServiceHost.detachService(this)
|
||||||
|
super.onDestroy()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onBind(intent: Intent?): IBinder? = null
|
||||||
|
|
||||||
|
fun onPartUpdate(part: DownloadItemPart) {
|
||||||
|
lastPart = part
|
||||||
|
val text = if (part.waitingForSpace) "Waiting for available storage" else "Downloading ${part.filename}"
|
||||||
|
val progress = part.progress.coerceIn(0L, 100L).toInt()
|
||||||
|
val notification = notification(text, progress, part.fileSize > 0L)
|
||||||
|
(getSystemService(NOTIFICATION_SERVICE) as NotificationManager).notify(NOTIFICATION_ID, notification)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onQueueChanged(hasWork: Boolean) {
|
||||||
|
if (!hasWork) {
|
||||||
|
stopForeground(STOP_FOREGROUND_REMOVE)
|
||||||
|
stopSelf()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun notification(text: String, progress: Int = 0, determinate: Boolean = false): Notification {
|
||||||
|
val cancelIntent = PendingIntent.getService(
|
||||||
|
this, 1, Intent(this, DownloadService::class.java).setAction(ACTION_CANCEL), pendingIntentFlags())
|
||||||
|
val retryIntent = PendingIntent.getService(
|
||||||
|
this, 2, Intent(this, DownloadService::class.java).setAction(ACTION_RETRY), pendingIntentFlags())
|
||||||
|
return NotificationCompat.Builder(this, CHANNEL_ID)
|
||||||
|
.setSmallIcon(R.drawable.icon)
|
||||||
|
.setContentTitle("Audiobookshelf downloads")
|
||||||
|
.setContentText(text)
|
||||||
|
.setOnlyAlertOnce(true)
|
||||||
|
.setOngoing(true)
|
||||||
|
.setProgress(100, progress, !determinate)
|
||||||
|
.addAction(0, "Cancel", cancelIntent)
|
||||||
|
.addAction(0, "Retry", retryIntent)
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createChannel() {
|
||||||
|
val manager = getSystemService(NOTIFICATION_SERVICE) as NotificationManager
|
||||||
|
manager.createNotificationChannel(NotificationChannel(CHANNEL_ID, "Downloads", NotificationManager.IMPORTANCE_LOW))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun pendingIntentFlags(): Int = PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val CHANNEL_ID = "downloads"
|
||||||
|
private const val NOTIFICATION_ID = 4102
|
||||||
|
private const val ACTION_CANCEL = "com.audiobookshelf.app.download.CANCEL"
|
||||||
|
private const val ACTION_RETRY = "com.audiobookshelf.app.download.RETRY"
|
||||||
|
fun intent(context: Context) = Intent(context, DownloadService::class.java)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
package com.audiobookshelf.app.services
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
import com.audiobookshelf.app.device.FolderScanner
|
||||||
|
import com.audiobookshelf.app.managers.DbManager
|
||||||
|
import com.audiobookshelf.app.managers.DownloadItemManager
|
||||||
|
import com.audiobookshelf.app.models.DownloadItem
|
||||||
|
import com.getcapacitor.JSObject
|
||||||
|
import java.util.Collections
|
||||||
|
|
||||||
|
/** Shared process owner used by the foreground service and the Capacitor bridge. */
|
||||||
|
object DownloadServiceHost {
|
||||||
|
private var manager: DownloadItemManager? = null
|
||||||
|
private var bridgeEmitter: DownloadItemManager.DownloadEventEmitter = NoopEmitter
|
||||||
|
private var service: DownloadService? = null
|
||||||
|
@Volatile private var bridgeReady = false
|
||||||
|
private val deferredCompletions = Collections.synchronizedList(mutableListOf<JSObject>())
|
||||||
|
|
||||||
|
@Synchronized
|
||||||
|
fun ensure(context: Context): DownloadItemManager {
|
||||||
|
if (manager == null) {
|
||||||
|
val appContext = context.applicationContext
|
||||||
|
DbManager.initialize(appContext)
|
||||||
|
manager = DownloadItemManager(FolderScanner(appContext), appContext, ForwardingEmitter)
|
||||||
|
manager!!.restoreQueue()
|
||||||
|
}
|
||||||
|
return manager!!
|
||||||
|
}
|
||||||
|
|
||||||
|
@Synchronized
|
||||||
|
fun attachBridge(context: Context, emitter: DownloadItemManager.DownloadEventEmitter) {
|
||||||
|
// Rehydrate the frontend's parent items before allowing part-progress events through.
|
||||||
|
// Otherwise a running restored queue can emit a part before Vue knows its DownloadItem.
|
||||||
|
bridgeReady = false
|
||||||
|
bridgeEmitter = emitter
|
||||||
|
val queue = ensure(context)
|
||||||
|
queue.setEventEmitter(ForwardingEmitter)
|
||||||
|
bridgeReady = true
|
||||||
|
val completions = synchronized(deferredCompletions) {
|
||||||
|
deferredCompletions.toList().also { deferredCompletions.clear() }
|
||||||
|
}
|
||||||
|
completions.forEach(bridgeEmitter::onDownloadItemComplete)
|
||||||
|
if (queue.hasWork()) startService(context)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Synchronized
|
||||||
|
fun detachBridge() {
|
||||||
|
bridgeReady = false
|
||||||
|
bridgeEmitter = NoopEmitter
|
||||||
|
}
|
||||||
|
|
||||||
|
@Synchronized
|
||||||
|
fun enqueue(context: Context, item: DownloadItem) {
|
||||||
|
ensure(context).addDownloadItem(item)
|
||||||
|
startService(context)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Synchronized
|
||||||
|
fun retryAll(context: Context) {
|
||||||
|
startService(context)
|
||||||
|
ensure(context).retryAll()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Synchronized
|
||||||
|
fun cancelAll(context: Context) { ensure(context).cancelAll() }
|
||||||
|
|
||||||
|
@Synchronized
|
||||||
|
fun attachService(downloadService: DownloadService) {
|
||||||
|
service = downloadService
|
||||||
|
service?.onQueueChanged(ensure(downloadService).hasWork())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Synchronized
|
||||||
|
fun detachService(downloadService: DownloadService) {
|
||||||
|
if (service === downloadService) service = null
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun startService(context: Context) {
|
||||||
|
ContextCompat.startForegroundService(context, DownloadService.intent(context))
|
||||||
|
}
|
||||||
|
|
||||||
|
private object ForwardingEmitter : DownloadItemManager.DownloadEventEmitter {
|
||||||
|
override fun onDownloadItem(downloadItem: DownloadItem) { bridgeEmitter.onDownloadItem(downloadItem) }
|
||||||
|
override fun onDownloadItemPartUpdate(downloadItemPart: com.audiobookshelf.app.models.DownloadItemPart) {
|
||||||
|
if (bridgeReady) bridgeEmitter.onDownloadItemPartUpdate(downloadItemPart)
|
||||||
|
service?.onPartUpdate(downloadItemPart)
|
||||||
|
}
|
||||||
|
override fun onDownloadItemComplete(jsobj: JSObject) {
|
||||||
|
if (bridgeReady) bridgeEmitter.onDownloadItemComplete(jsobj) else deferredCompletions.add(jsobj)
|
||||||
|
}
|
||||||
|
override fun onQueueChanged(hasWork: Boolean) {
|
||||||
|
bridgeEmitter.onQueueChanged(hasWork)
|
||||||
|
service?.onQueueChanged(hasWork)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private object NoopEmitter : DownloadItemManager.DownloadEventEmitter {
|
||||||
|
override fun onDownloadItem(downloadItem: DownloadItem) = Unit
|
||||||
|
override fun onDownloadItemPartUpdate(downloadItemPart: com.audiobookshelf.app.models.DownloadItemPart) = Unit
|
||||||
|
override fun onDownloadItemComplete(jsobj: JSObject) = Unit
|
||||||
|
override fun onQueueChanged(hasWork: Boolean) = Unit
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user