mirror of
https://github.com/advplyr/audiobookshelf-app.git
synced 2026-09-09 19:31:51 +02:00
Add AbsLogger to download paths
This commit is contained in:
@@ -60,9 +60,6 @@ android {
|
|||||||
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
|
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
testOptions {
|
|
||||||
unitTests.returnDefaultValues = true
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
repositories {
|
repositories {
|
||||||
@@ -84,7 +81,6 @@ 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"
|
||||||
|
|||||||
@@ -3,12 +3,12 @@ package com.audiobookshelf.app.managers
|
|||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import android.os.StatFs
|
import android.os.StatFs
|
||||||
import android.util.Log
|
|
||||||
import androidx.documentfile.provider.DocumentFile
|
import androidx.documentfile.provider.DocumentFile
|
||||||
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
|
||||||
import com.audiobookshelf.app.models.DownloadItemPart
|
import com.audiobookshelf.app.models.DownloadItemPart
|
||||||
|
import com.audiobookshelf.app.plugins.AbsLogger
|
||||||
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
|
||||||
@@ -72,7 +72,7 @@ class DownloadItemManager(
|
|||||||
DeviceManager.dbManager.getDownloadItems().forEach { item ->
|
DeviceManager.dbManager.getDownloadItems().forEach { item ->
|
||||||
item.downloadItemParts.filter { it.moved }.forEach { part ->
|
item.downloadItemParts.filter { it.moved }.forEach { part ->
|
||||||
if (!finalizedFileExists(part)) {
|
if (!finalizedFileExists(part)) {
|
||||||
Log.w(tag, "Finalized file is missing; resetting ${part.filename}")
|
AbsLogger.error(tag, "Finalized file is missing; resetting ${part.filename}")
|
||||||
part.moved = false
|
part.moved = false
|
||||||
part.completed = false
|
part.completed = false
|
||||||
part.completedDestinationUri = null
|
part.completedDestinationUri = null
|
||||||
@@ -161,7 +161,10 @@ class DownloadItemManager(
|
|||||||
try {
|
try {
|
||||||
DocumentFile.fromSingleUri(context, Uri.parse(uri))?.delete()
|
DocumentFile.fromSingleUri(context, Uri.parse(uri))?.delete()
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.w(tag, "Could not delete cancelled SAF file ${part.filename}", e)
|
AbsLogger.error(
|
||||||
|
tag,
|
||||||
|
"Could not delete cancelled SAF file ${part.filename}: ${e.message}"
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -177,11 +180,12 @@ class DownloadItemManager(
|
|||||||
|
|
||||||
@Synchronized
|
@Synchronized
|
||||||
fun hasWork(): Boolean =
|
fun hasWork(): Boolean =
|
||||||
finalizingItems.isNotEmpty() || downloadItemQueue.any { item ->
|
finalizingItems.isNotEmpty() ||
|
||||||
item.downloadItemParts.any { part ->
|
downloadItemQueue.any { item ->
|
||||||
(!part.moved && !part.failed) || part.isMoving
|
item.downloadItemParts.any { part ->
|
||||||
}
|
(!part.moved && !part.failed) || part.isMoving
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Synchronized
|
@Synchronized
|
||||||
private fun checkUpdateDownloadQueue() {
|
private fun checkUpdateDownloadQueue() {
|
||||||
@@ -190,8 +194,12 @@ class DownloadItemManager(
|
|||||||
if (slots <= 0) return@forEach
|
if (slots <= 0) return@forEach
|
||||||
item.downloadItemParts
|
item.downloadItemParts
|
||||||
.filter { part ->
|
.filter { part ->
|
||||||
part.completed && !part.moved && !part.failed && !part.isMoving &&
|
part.completed &&
|
||||||
part !in currentDownloadItemParts && File(part.destinationPath).exists() &&
|
!part.moved &&
|
||||||
|
!part.failed &&
|
||||||
|
!part.isMoving &&
|
||||||
|
part !in currentDownloadItemParts &&
|
||||||
|
File(part.destinationPath).exists() &&
|
||||||
!hasActiveDestinationConflict(part)
|
!hasActiveDestinationConflict(part)
|
||||||
}
|
}
|
||||||
.take(slots)
|
.take(slots)
|
||||||
@@ -238,6 +246,7 @@ class DownloadItemManager(
|
|||||||
part.lastUpdateTime = System.currentTimeMillis()
|
part.lastUpdateTime = System.currentTimeMillis()
|
||||||
currentDownloadItemParts.add(part)
|
currentDownloadItemParts.add(part)
|
||||||
persist(item, force = true)
|
persist(item, force = true)
|
||||||
|
AbsLogger.info(tag, "Starting download for ${part.filename}")
|
||||||
val activeConfig = DeviceManager.serverConnectionConfig
|
val activeConfig = DeviceManager.serverConnectionConfig
|
||||||
val token =
|
val token =
|
||||||
if (activeConfig?.id == item.serverConnectionConfigId) activeConfig.token
|
if (activeConfig?.id == item.serverConnectionConfigId) activeConfig.token
|
||||||
@@ -271,7 +280,8 @@ class DownloadItemManager(
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
{ hasAvailableSpace(part) }
|
{ hasAvailableSpace(part) }
|
||||||
).download(serverUrl(item, part), token)
|
)
|
||||||
|
.download(serverUrl(item, part), token)
|
||||||
if (part in currentDownloadItemParts && !part.completed && !part.failed) {
|
if (part in currentDownloadItemParts && !part.completed && !part.failed) {
|
||||||
activeCalls[part.id] = handle
|
activeCalls[part.id] = handle
|
||||||
}
|
}
|
||||||
@@ -310,7 +320,7 @@ class DownloadItemManager(
|
|||||||
if (!part.completed && !part.failed) {
|
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.w(tag, "Download stalled: ${part.filename}")
|
AbsLogger.error(tag, "Download stalled: ${part.filename}")
|
||||||
activeCalls.remove(part.id)?.cancel()
|
activeCalls.remove(part.id)?.cancel()
|
||||||
failOrRetry(item, part, "Download stalled")
|
failOrRetry(item, part, "Download stalled")
|
||||||
}
|
}
|
||||||
@@ -329,7 +339,7 @@ class DownloadItemManager(
|
|||||||
part.retryCount += 1
|
part.retryCount += 1
|
||||||
reservations.remove(part.destinationPath)
|
reservations.remove(part.destinationPath)
|
||||||
if (part.retryCount > MAX_RETRIES) {
|
if (part.retryCount > MAX_RETRIES) {
|
||||||
Log.e(tag, "$reason after $MAX_RETRIES retries: ${part.filename}")
|
AbsLogger.error(tag, "$reason after $MAX_RETRIES retries: ${part.filename}")
|
||||||
part.failed = true
|
part.failed = true
|
||||||
part.completed = false
|
part.completed = false
|
||||||
part.downloadId = null
|
part.downloadId = null
|
||||||
@@ -411,7 +421,7 @@ class DownloadItemManager(
|
|||||||
?: 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")
|
||||||
if (!staging.delete()) Log.w(tag, "Could not remove staging file ${staging.name}")
|
if (!staging.delete()) AbsLogger.error(tag, "Could not remove staging file ${staging.name}")
|
||||||
part.completedDestinationUri = destination.uri.toString()
|
part.completedDestinationUri = destination.uri.toString()
|
||||||
completePart(item, part)
|
completePart(item, part)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
@@ -422,7 +432,7 @@ class DownloadItemManager(
|
|||||||
|
|
||||||
@Synchronized
|
@Synchronized
|
||||||
private fun failFinalization(item: DownloadItem, part: DownloadItemPart, message: String) {
|
private fun failFinalization(item: DownloadItem, part: DownloadItemPart, message: String) {
|
||||||
Log.e(tag, message)
|
AbsLogger.error(tag, message)
|
||||||
part.isMoving = false
|
part.isMoving = false
|
||||||
part.failed = true
|
part.failed = true
|
||||||
failOrRetry(item, part, message)
|
failOrRetry(item, part, message)
|
||||||
@@ -562,6 +572,7 @@ class DownloadItemManager(
|
|||||||
part.bytesDownloaded = file.length()
|
part.bytesDownloaded = file.length()
|
||||||
part.progress = 100L
|
part.progress = 100L
|
||||||
part.reusedExistingFile = true
|
part.reusedExistingFile = true
|
||||||
|
AbsLogger.info(tag, "Reusing existing cover ${part.filename}")
|
||||||
File(part.destinationPath).delete()
|
File(part.destinationPath).delete()
|
||||||
completePart(item, part)
|
completePart(item, part)
|
||||||
clientEventEmitter.onDownloadItemPartUpdate(part)
|
clientEventEmitter.onDownloadItemPartUpdate(part)
|
||||||
@@ -570,7 +581,8 @@ class DownloadItemManager(
|
|||||||
|
|
||||||
private fun hasActiveDestinationConflict(part: DownloadItemPart): Boolean =
|
private fun hasActiveDestinationConflict(part: DownloadItemPart): Boolean =
|
||||||
currentDownloadItemParts.any { activePart ->
|
currentDownloadItemParts.any { activePart ->
|
||||||
activePart !== part && activePart.localFolderId == part.localFolderId &&
|
activePart !== part &&
|
||||||
|
activePart.localFolderId == part.localFolderId &&
|
||||||
activePart.finalDestinationPath == part.finalDestinationPath
|
activePart.finalDestinationPath == part.finalDestinationPath
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -594,10 +606,10 @@ class DownloadItemManager(
|
|||||||
part.completedDestinationUri?.let { uri ->
|
part.completedDestinationUri?.let { uri ->
|
||||||
try {
|
try {
|
||||||
val file = DocumentFile.fromSingleUri(context, Uri.parse(uri))
|
val file = DocumentFile.fromSingleUri(context, Uri.parse(uri))
|
||||||
if (file?.isFile == true &&
|
if (file?.isFile == true && (part.fileSize <= 0L || file.length() == part.fileSize))
|
||||||
(part.fileSize <= 0L || file.length() == part.fileSize)) return true
|
return true
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.w(tag, "Could not validate SAF file ${part.filename}", e)
|
AbsLogger.error(tag, "Could not validate SAF file ${part.filename}: ${e.message}")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return findSharedStorageFile(part) != null
|
return findSharedStorageFile(part) != null
|
||||||
@@ -607,7 +619,7 @@ class DownloadItemManager(
|
|||||||
private fun resetPartForFreshDownload(part: DownloadItemPart): Boolean {
|
private fun resetPartForFreshDownload(part: DownloadItemPart): Boolean {
|
||||||
val stagingFile = File(part.destinationPath)
|
val stagingFile = File(part.destinationPath)
|
||||||
if (stagingFile.exists() && !stagingFile.delete()) {
|
if (stagingFile.exists() && !stagingFile.delete()) {
|
||||||
Log.e(tag, "Could not delete staging file ${part.filename}")
|
AbsLogger.error(tag, "Could not delete staging file ${part.filename}")
|
||||||
part.failed = true
|
part.failed = true
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
@@ -624,11 +636,14 @@ class DownloadItemManager(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun findDocumentByFilename(folder: DocumentFile, part: DownloadItemPart): DocumentFile? {
|
private fun findDocumentByFilename(folder: DocumentFile, part: DownloadItemPart): DocumentFile? {
|
||||||
folder.findFile(part.filename)?.let { return it }
|
folder.findFile(part.filename)?.let {
|
||||||
|
return it
|
||||||
|
}
|
||||||
val expectedBaseName = part.filename.substringBeforeLast('.')
|
val expectedBaseName = part.filename.substringBeforeLast('.')
|
||||||
return folder.listFiles().firstOrNull { document ->
|
return folder.listFiles().firstOrNull { document ->
|
||||||
document.name == part.filename ||
|
document.name == part.filename ||
|
||||||
(part.audioTrack != null && document.isFile &&
|
(part.audioTrack != null &&
|
||||||
|
document.isFile &&
|
||||||
(document.name ?: "").substringBeforeLast('.') == expectedBaseName)
|
(document.name ?: "").substringBeforeLast('.') == expectedBaseName)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+4
-4
@@ -1,7 +1,6 @@
|
|||||||
package com.audiobookshelf.app.managers
|
package com.audiobookshelf.app.managers
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.util.Log
|
|
||||||
import androidx.work.ExistingWorkPolicy
|
import androidx.work.ExistingWorkPolicy
|
||||||
import androidx.work.OneTimeWorkRequestBuilder
|
import androidx.work.OneTimeWorkRequestBuilder
|
||||||
import androidx.work.WorkManager
|
import androidx.work.WorkManager
|
||||||
@@ -9,6 +8,7 @@ import androidx.work.Worker
|
|||||||
import androidx.work.WorkerParameters
|
import androidx.work.WorkerParameters
|
||||||
import com.audiobookshelf.app.device.DeviceManager
|
import com.audiobookshelf.app.device.DeviceManager
|
||||||
import com.audiobookshelf.app.models.DownloadItem
|
import com.audiobookshelf.app.models.DownloadItem
|
||||||
|
import com.audiobookshelf.app.plugins.AbsLogger
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.util.concurrent.TimeUnit
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
@@ -64,7 +64,7 @@ object IncompleteDownloadCleanup {
|
|||||||
item.stagingCleanupAt = System.currentTimeMillis()
|
item.stagingCleanupAt = System.currentTimeMillis()
|
||||||
DeviceManager.dbManager.saveDownloadItem(item)
|
DeviceManager.dbManager.saveDownloadItem(item)
|
||||||
cancel(context, item.id)
|
cancel(context, item.id)
|
||||||
Log.i(tag, "Deleted staging files for terminally failed download item ${item.id}")
|
AbsLogger.info(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) {
|
||||||
@@ -72,10 +72,10 @@ object IncompleteDownloadCleanup {
|
|||||||
val internal = context.filesDir.absolutePath
|
val internal = context.filesDir.absolutePath
|
||||||
val external = context.getExternalFilesDir(null)?.absolutePath
|
val external = context.getExternalFilesDir(null)?.absolutePath
|
||||||
if (path.startsWith(internal) || (external != null && path.startsWith(external))) {
|
if (path.startsWith(internal) || (external != null && path.startsWith(external))) {
|
||||||
if (file.exists() && !file.delete()) Log.w(tag, "Could not delete expired staging file $path")
|
if (file.exists() && !file.delete()) AbsLogger.error(tag, "Could not delete expired staging file $path")
|
||||||
file.parentFile?.takeIf { it.isDirectory && it.list()?.isEmpty() == true }?.delete()
|
file.parentFile?.takeIf { it.isDirectory && it.list()?.isEmpty() == true }?.delete()
|
||||||
} else {
|
} else {
|
||||||
Log.w(tag, "Refusing to delete non-app-owned path $path")
|
AbsLogger.error(tag, "Refusing to delete non-app-owned path $path")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+14
-11
@@ -1,6 +1,6 @@
|
|||||||
package com.audiobookshelf.app.managers
|
package com.audiobookshelf.app.managers
|
||||||
|
|
||||||
import android.util.Log
|
import com.audiobookshelf.app.plugins.AbsLogger
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.io.FileOutputStream
|
import java.io.FileOutputStream
|
||||||
import java.io.IOException
|
import java.io.IOException
|
||||||
@@ -61,6 +61,9 @@ class InternalDownloadManager(
|
|||||||
allowRestart: Boolean
|
allowRestart: Boolean
|
||||||
) {
|
) {
|
||||||
var existingBytes = destinationFile.takeIf { it.exists() }?.length() ?: 0L
|
var existingBytes = destinationFile.takeIf { it.exists() }?.length() ?: 0L
|
||||||
|
AbsLogger.info(
|
||||||
|
tag,
|
||||||
|
"Starting ${if (existingBytes > 0L) "resumed" else "new"} download for ${destinationFile.name} at byte $existingBytes")
|
||||||
if (expectedSize > 0L && existingBytes == expectedSize) {
|
if (expectedSize > 0L && existingBytes == expectedSize) {
|
||||||
progressCallback.onProgress(existingBytes, 100L)
|
progressCallback.onProgress(existingBytes, 100L)
|
||||||
progressCallback.onComplete(false)
|
progressCallback.onComplete(false)
|
||||||
@@ -68,7 +71,7 @@ class InternalDownloadManager(
|
|||||||
}
|
}
|
||||||
if (expectedSize > 0L && existingBytes > expectedSize) {
|
if (expectedSize > 0L && existingBytes > expectedSize) {
|
||||||
if (!destinationFile.delete()) {
|
if (!destinationFile.delete()) {
|
||||||
Log.e(tag, "Could not delete oversized staging file ${destinationFile.name}")
|
AbsLogger.error(tag, "Could not delete oversized staging file ${destinationFile.name}")
|
||||||
progressCallback.onComplete(true)
|
progressCallback.onComplete(true)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -86,7 +89,7 @@ class InternalDownloadManager(
|
|||||||
call.enqueue(
|
call.enqueue(
|
||||||
object : Callback {
|
object : Callback {
|
||||||
override fun onFailure(call: Call, e: IOException) {
|
override fun onFailure(call: Call, e: IOException) {
|
||||||
Log.e(tag, "Download URL failed", e)
|
AbsLogger.error(tag, "Download request failed for ${destinationFile.name}: ${e.message}")
|
||||||
progressCallback.onComplete(true)
|
progressCallback.onComplete(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -102,10 +105,10 @@ class InternalDownloadManager(
|
|||||||
progressCallback.onProgress(existingBytes, 100L)
|
progressCallback.onProgress(existingBytes, 100L)
|
||||||
progressCallback.onComplete(false)
|
progressCallback.onComplete(false)
|
||||||
} else if (allowRestart && destinationFile.delete()) {
|
} else if (allowRestart && destinationFile.delete()) {
|
||||||
Log.w(tag, "Restarting stale range from byte zero")
|
AbsLogger.info(tag, "Restarting stale range from byte zero for ${destinationFile.name}")
|
||||||
startRequest(url, token, handle, allowRestart = false)
|
startRequest(url, token, handle, allowRestart = false)
|
||||||
} else {
|
} else {
|
||||||
Log.e(tag, "Could not recover invalid range at offset $existingBytes")
|
AbsLogger.error(tag, "Could not recover invalid range for ${destinationFile.name} at byte $existingBytes")
|
||||||
progressCallback.onComplete(true)
|
progressCallback.onComplete(true)
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
@@ -115,15 +118,15 @@ class InternalDownloadManager(
|
|||||||
response.code == 206 &&
|
response.code == 206 &&
|
||||||
hasExpectedRange(response, existingBytes)
|
hasExpectedRange(response, existingBytes)
|
||||||
if (existingBytes > 0L && !append && response.code != 200) {
|
if (existingBytes > 0L && !append && response.code != 200) {
|
||||||
Log.e(
|
AbsLogger.error(
|
||||||
tag,
|
tag,
|
||||||
"Invalid resume response ${response.code} for offset $existingBytes"
|
"Invalid resume response ${response.code} for ${destinationFile.name} at byte $existingBytes"
|
||||||
)
|
)
|
||||||
progressCallback.onComplete(true)
|
progressCallback.onComplete(true)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (!response.isSuccessful || response.body == null) {
|
if (!response.isSuccessful || response.body == null) {
|
||||||
Log.e(tag, "Download HTTP failure ${response.code}")
|
AbsLogger.error(tag, "Download HTTP failure ${response.code} for ${destinationFile.name}")
|
||||||
progressCallback.onComplete(true)
|
progressCallback.onComplete(true)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -153,16 +156,16 @@ class InternalDownloadManager(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (expectedSize > 0L && destinationFile.length() != expectedSize) {
|
if (expectedSize > 0L && destinationFile.length() != expectedSize) {
|
||||||
Log.e(
|
AbsLogger.error(
|
||||||
tag,
|
tag,
|
||||||
"Downloaded size ${destinationFile.length()} did not match $expectedSize"
|
"Downloaded size for ${destinationFile.name} was ${destinationFile.length()}, expected $expectedSize"
|
||||||
)
|
)
|
||||||
progressCallback.onComplete(true)
|
progressCallback.onComplete(true)
|
||||||
} else {
|
} else {
|
||||||
progressCallback.onComplete(false)
|
progressCallback.onComplete(false)
|
||||||
}
|
}
|
||||||
} catch (e: IOException) {
|
} catch (e: IOException) {
|
||||||
Log.e(tag, "Could not write staging file", e)
|
AbsLogger.error(tag, "Could not write staging file ${destinationFile.name}: ${e.message}")
|
||||||
progressCallback.onComplete(true)
|
progressCallback.onComplete(true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
package com.audiobookshelf.app.plugins
|
package com.audiobookshelf.app.plugins
|
||||||
|
|
||||||
import android.os.Environment
|
import android.os.Environment
|
||||||
import android.util.Log
|
|
||||||
import com.audiobookshelf.app.MainActivity
|
import com.audiobookshelf.app.MainActivity
|
||||||
import com.audiobookshelf.app.data.*
|
import com.audiobookshelf.app.data.*
|
||||||
import com.audiobookshelf.app.device.DeviceManager
|
import com.audiobookshelf.app.device.DeviceManager
|
||||||
@@ -84,14 +83,14 @@ class AbsDownloader : Plugin() {
|
|||||||
var episodeId = call.data.getString("episodeId").toString()
|
var episodeId = call.data.getString("episodeId").toString()
|
||||||
if (episodeId == "null") episodeId = ""
|
if (episodeId == "null") episodeId = ""
|
||||||
var localFolderId = call.data.getString("localFolderId", "").toString()
|
var localFolderId = call.data.getString("localFolderId", "").toString()
|
||||||
Log.d(tag, "Download library item $libraryItemId to folder $localFolderId / episode: $episodeId")
|
AbsLogger.info(tag, "Requested download for item $libraryItemId${if (episodeId.isEmpty()) "" else " / episode $episodeId"}")
|
||||||
|
|
||||||
val downloadId = if (episodeId.isEmpty()) libraryItemId else "$libraryItemId-$episodeId"
|
val downloadId = if (episodeId.isEmpty()) libraryItemId else "$libraryItemId-$episodeId"
|
||||||
DownloadServiceHost.retryExisting(mainActivity, downloadId) { result ->
|
DownloadServiceHost.retryExisting(mainActivity, downloadId) { result ->
|
||||||
when (result) {
|
when (result) {
|
||||||
DownloadServiceHost.ExistingDownloadResult.RETRIED -> call.resolve()
|
DownloadServiceHost.ExistingDownloadResult.RETRIED -> call.resolve()
|
||||||
DownloadServiceHost.ExistingDownloadResult.ACTIVE -> {
|
DownloadServiceHost.ExistingDownloadResult.ACTIVE -> {
|
||||||
Log.d(tag, "Download already started for this media entity $downloadId")
|
AbsLogger.info(tag, "Download already active for $downloadId")
|
||||||
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 ->
|
DownloadServiceHost.ExistingDownloadResult.SERVICE_START_FAILED ->
|
||||||
@@ -101,12 +100,12 @@ class AbsDownloader : Plugin() {
|
|||||||
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}")
|
AbsLogger.info(tag, "Preparing download for ${libraryItem.id}")
|
||||||
|
|
||||||
if (localFolderId == "") localFolderId = "internal-${libraryItem.mediaType}"
|
if (localFolderId == "") 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")
|
AbsLogger.info(tag, "Creating internal download folder $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)
|
||||||
}
|
}
|
||||||
@@ -174,14 +173,13 @@ class AbsDownloader : Plugin() {
|
|||||||
"${mainActivity.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS) ?: mainActivity.filesDir}/download-staging/${libraryItem.id}"
|
"${mainActivity.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS) ?: mainActivity.filesDir}/download-staging/${libraryItem.id}"
|
||||||
}
|
}
|
||||||
|
|
||||||
Log.d(tag, "downloadCacheDirectory=$tempFolderPath")
|
|
||||||
|
|
||||||
if (libraryItem.mediaType == "book") {
|
if (libraryItem.mediaType == "book") {
|
||||||
val bookTitle = cleanStringForFileSystem(libraryItem.media.metadata.title)
|
val bookTitle = cleanStringForFileSystem(libraryItem.media.metadata.title)
|
||||||
val bookAuthor = cleanStringForFileSystem(libraryItem.media.metadata.getAuthorDisplayName())
|
val bookAuthor = cleanStringForFileSystem(libraryItem.media.metadata.getAuthorDisplayName())
|
||||||
|
|
||||||
val tracks = libraryItem.media.getAudioTracks()
|
val tracks = libraryItem.media.getAudioTracks()
|
||||||
Log.d(tag, "Starting library item download with ${tracks.size} tracks")
|
AbsLogger.info(tag, "Queueing library item download with ${tracks.size} files")
|
||||||
val itemSubfolder = "$bookAuthor/$bookTitle"
|
val itemSubfolder = "$bookAuthor/$bookTitle"
|
||||||
val itemFolderPath = if (isInternal) finalInternalFolderPath else "${localFolder.absolutePath}/$itemSubfolder"
|
val itemFolderPath = if (isInternal) finalInternalFolderPath else "${localFolder.absolutePath}/$itemSubfolder"
|
||||||
val downloadItem = DownloadItem(libraryItem.id, libraryItem.id, null, libraryItem.userMediaProgress,DeviceManager.serverConnectionConfig?.id ?: "", DeviceManager.serverAddress, DeviceManager.serverUserId, libraryItem.mediaType, itemFolderPath, localFolder, bookTitle, itemSubfolder, libraryItem.media, mutableListOf())
|
val downloadItem = DownloadItem(libraryItem.id, libraryItem.id, null, libraryItem.userMediaProgress,DeviceManager.serverConnectionConfig?.id ?: "", DeviceManager.serverAddress, DeviceManager.serverUserId, libraryItem.mediaType, itemFolderPath, localFolder, bookTitle, itemSubfolder, libraryItem.media, mutableListOf())
|
||||||
@@ -208,7 +206,6 @@ class AbsDownloader : Plugin() {
|
|||||||
|
|
||||||
val serverPath = "/api/items/${libraryItem.id}/file/${audioFileIno}/download"
|
val serverPath = "/api/items/${libraryItem.id}/file/${audioFileIno}/download"
|
||||||
val destinationFilename = getFilenameFromRelPath(audioTrack.relPath)
|
val destinationFilename = getFilenameFromRelPath(audioTrack.relPath)
|
||||||
Log.d(tag, "Audio File Server Path $serverPath | AF RelPath ${audioTrack.relPath} | LocalFolder Path ${localFolder.absolutePath} | DestName $destinationFilename")
|
|
||||||
|
|
||||||
val finalDestinationFile = File("$itemFolderPath/$destinationFilename")
|
val finalDestinationFile = File("$itemFolderPath/$destinationFilename")
|
||||||
val destinationFile = File("$tempFolderPath/$destinationFilename.part")
|
val destinationFile = File("$tempFolderPath/$destinationFilename.part")
|
||||||
@@ -242,14 +239,13 @@ class AbsDownloader : Plugin() {
|
|||||||
val audioFileIno = episode?.audioFile?.ino
|
val audioFileIno = episode?.audioFile?.ino
|
||||||
val fileSize = audioTrack?.metadata?.size ?: 0
|
val fileSize = audioTrack?.metadata?.size ?: 0
|
||||||
|
|
||||||
Log.d(tag, "Starting podcast episode download")
|
AbsLogger.info(tag, "Queueing podcast episode download")
|
||||||
val itemFolderPath = if (isInternal) finalInternalFolderPath else "${localFolder.absolutePath}/$podcastTitle"
|
val itemFolderPath = if (isInternal) finalInternalFolderPath else "${localFolder.absolutePath}/$podcastTitle"
|
||||||
val downloadItemId = "${libraryItem.id}-${episode?.id}"
|
val downloadItemId = "${libraryItem.id}-${episode?.id}"
|
||||||
val downloadItem = DownloadItem(downloadItemId, libraryItem.id, episode?.id, libraryItem.userMediaProgress, DeviceManager.serverConnectionConfig?.id ?: "", DeviceManager.serverAddress, DeviceManager.serverUserId, libraryItem.mediaType, itemFolderPath, localFolder, podcastTitle, podcastTitle, libraryItem.media, mutableListOf())
|
val downloadItem = DownloadItem(downloadItemId, libraryItem.id, episode?.id, libraryItem.userMediaProgress, DeviceManager.serverConnectionConfig?.id ?: "", DeviceManager.serverAddress, DeviceManager.serverUserId, libraryItem.mediaType, itemFolderPath, localFolder, podcastTitle, podcastTitle, libraryItem.media, mutableListOf())
|
||||||
|
|
||||||
var serverPath = "/api/items/${libraryItem.id}/file/${audioFileIno}/download"
|
var serverPath = "/api/items/${libraryItem.id}/file/${audioFileIno}/download"
|
||||||
var destinationFilename = getFilenameFromRelPath(audioTrack?.relPath ?: "")
|
var destinationFilename = getFilenameFromRelPath(audioTrack?.relPath ?: "")
|
||||||
Log.d(tag, "Audio File Server Path $serverPath | AF RelPath ${audioTrack?.relPath} | LocalFolder Path ${localFolder.absolutePath} | DestName $destinationFilename")
|
|
||||||
|
|
||||||
var destinationFile = File("$tempFolderPath/$destinationFilename.part")
|
var destinationFile = File("$tempFolderPath/$destinationFilename.part")
|
||||||
var finalDestinationFile = File("$itemFolderPath/$destinationFilename")
|
var finalDestinationFile = File("$itemFolderPath/$destinationFilename")
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
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.managers.IncompleteDownloadCleanup
|
||||||
import com.audiobookshelf.app.models.DownloadItem
|
import com.audiobookshelf.app.models.DownloadItem
|
||||||
|
import com.audiobookshelf.app.plugins.AbsLogger
|
||||||
import com.getcapacitor.JSObject
|
import com.getcapacitor.JSObject
|
||||||
import java.util.Collections
|
import java.util.Collections
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
@@ -171,7 +171,7 @@ object DownloadServiceHost {
|
|||||||
ContextCompat.startForegroundService(context, DownloadService.intent(context))
|
ContextCompat.startForegroundService(context, DownloadService.intent(context))
|
||||||
true
|
true
|
||||||
} catch (e: RuntimeException) {
|
} catch (e: RuntimeException) {
|
||||||
Log.e(TAG, "Could not start download foreground service", e)
|
AbsLogger.error(TAG, "Could not start download foreground service: ${e.message}")
|
||||||
false
|
false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
-176
@@ -1,176 +0,0 @@
|
|||||||
package com.audiobookshelf.app.managers
|
|
||||||
|
|
||||||
import java.io.Closeable
|
|
||||||
import java.net.ServerSocket
|
|
||||||
import java.nio.file.Files
|
|
||||||
import java.util.Collections
|
|
||||||
import java.util.concurrent.CountDownLatch
|
|
||||||
import java.util.concurrent.TimeUnit
|
|
||||||
import java.util.concurrent.atomic.AtomicBoolean
|
|
||||||
import org.junit.After
|
|
||||||
import org.junit.Assert.assertEquals
|
|
||||||
import org.junit.Assert.assertFalse
|
|
||||||
import org.junit.Assert.assertNull
|
|
||||||
import org.junit.Assert.assertTrue
|
|
||||||
import org.junit.Test
|
|
||||||
|
|
||||||
class InternalDownloadManagerTest {
|
|
||||||
private var server: TestHttpServer? = null
|
|
||||||
|
|
||||||
@After
|
|
||||||
fun tearDown() {
|
|
||||||
server?.close()
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun exactKnownStagingFileCompletesWithoutHttpRequest() {
|
|
||||||
val destination = Files.createTempFile("abs-complete", ".part").toFile()
|
|
||||||
destination.writeBytes(byteArrayOf(1, 2, 3, 4))
|
|
||||||
val callback = RecordingCallback()
|
|
||||||
|
|
||||||
InternalDownloadManager(destination, 4L, callback) { true }
|
|
||||||
.download("http://127.0.0.1:1/download", "token")
|
|
||||||
|
|
||||||
assertTrue(callback.completed.await(1, TimeUnit.SECONDS))
|
|
||||||
assertFalse(callback.failed.get())
|
|
||||||
assertEquals(4L, destination.length())
|
|
||||||
destination.delete()
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun unknownSizeFullFileIsAcceptedFrom416ContentRange() {
|
|
||||||
server = TestHttpServer { _, _ ->
|
|
||||||
response(416, headers = listOf("Content-Range: bytes */4"))
|
|
||||||
}
|
|
||||||
val destination = Files.createTempFile("abs-unknown", ".part").toFile()
|
|
||||||
destination.writeBytes(byteArrayOf(1, 2, 3, 4))
|
|
||||||
val callback = RecordingCallback()
|
|
||||||
|
|
||||||
InternalDownloadManager(destination, 0L, callback) { true }
|
|
||||||
.download(server!!.url, "token")
|
|
||||||
|
|
||||||
assertTrue(callback.completed.await(3, TimeUnit.SECONDS))
|
|
||||||
assertFalse(callback.failed.get())
|
|
||||||
assertEquals("bytes=4-", server!!.requests.single()["range"])
|
|
||||||
destination.delete()
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun partialFileResumesWithRangeDuringLiveRetry() {
|
|
||||||
server = TestHttpServer { _, _ ->
|
|
||||||
response(206, byteArrayOf(3, 4), listOf("Content-Range: bytes 2-3/4"))
|
|
||||||
}
|
|
||||||
val destination = Files.createTempFile("abs-partial", ".part").toFile()
|
|
||||||
destination.writeBytes(byteArrayOf(1, 2))
|
|
||||||
val callback = RecordingCallback()
|
|
||||||
|
|
||||||
InternalDownloadManager(destination, 4L, callback) { true }
|
|
||||||
.download(server!!.url, "token")
|
|
||||||
|
|
||||||
assertTrue(callback.completed.await(3, TimeUnit.SECONDS))
|
|
||||||
assertFalse(callback.failed.get())
|
|
||||||
assertEquals("bytes=2-", server!!.requests.single()["range"])
|
|
||||||
assertTrue(destination.readBytes().contentEquals(byteArrayOf(1, 2, 3, 4)))
|
|
||||||
destination.delete()
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun stale416RestartsOnceFromByteZero() {
|
|
||||||
server = TestHttpServer { index, _ ->
|
|
||||||
if (index == 0) response(416, headers = listOf("Content-Range: bytes */2"))
|
|
||||||
else response(200, byteArrayOf(9, 8))
|
|
||||||
}
|
|
||||||
val destination = Files.createTempFile("abs-stale", ".part").toFile()
|
|
||||||
destination.writeBytes(byteArrayOf(1, 2, 3, 4))
|
|
||||||
val callback = RecordingCallback()
|
|
||||||
|
|
||||||
InternalDownloadManager(destination, 0L, callback) { true }
|
|
||||||
.download(server!!.url, "token")
|
|
||||||
|
|
||||||
assertTrue(callback.completed.await(3, TimeUnit.SECONDS))
|
|
||||||
assertFalse(callback.failed.get())
|
|
||||||
assertEquals(2, server!!.requests.size)
|
|
||||||
assertEquals("bytes=4-", server!!.requests[0]["range"])
|
|
||||||
assertNull(server!!.requests[1]["range"])
|
|
||||||
assertTrue(destination.readBytes().contentEquals(byteArrayOf(9, 8)))
|
|
||||||
destination.delete()
|
|
||||||
}
|
|
||||||
|
|
||||||
private class RecordingCallback : DownloadItemManager.InternalProgressCallback {
|
|
||||||
val completed = CountDownLatch(1)
|
|
||||||
val failed = AtomicBoolean(true)
|
|
||||||
|
|
||||||
override fun onProgress(totalBytesWritten: Long, progress: Long) = Unit
|
|
||||||
|
|
||||||
override fun onComplete(failed: Boolean) {
|
|
||||||
this.failed.set(failed)
|
|
||||||
completed.countDown()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private class TestHttpServer(
|
|
||||||
private val responder: (Int, Map<String, String>) -> ByteArray
|
|
||||||
) : Closeable {
|
|
||||||
private val socket = ServerSocket(0)
|
|
||||||
val requests = Collections.synchronizedList(mutableListOf<Map<String, String>>())
|
|
||||||
val url = "http://127.0.0.1:${socket.localPort}/download"
|
|
||||||
private val thread = Thread {
|
|
||||||
while (!socket.isClosed) {
|
|
||||||
try {
|
|
||||||
socket.accept().use { connection ->
|
|
||||||
val reader = connection.getInputStream().bufferedReader()
|
|
||||||
reader.readLine()
|
|
||||||
val headers = mutableMapOf<String, String>()
|
|
||||||
while (true) {
|
|
||||||
val line = reader.readLine() ?: break
|
|
||||||
if (line.isEmpty()) break
|
|
||||||
val separator = line.indexOf(':')
|
|
||||||
if (separator > 0) {
|
|
||||||
headers[line.substring(0, separator).lowercase()] =
|
|
||||||
line.substring(separator + 1).trim()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
val index = requests.size
|
|
||||||
requests.add(headers)
|
|
||||||
connection.getOutputStream().use { output ->
|
|
||||||
output.write(responder(index, headers))
|
|
||||||
output.flush()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (_: Exception) {
|
|
||||||
if (!socket.isClosed) throw IllegalStateException("Test HTTP server failed")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}.apply {
|
|
||||||
isDaemon = true
|
|
||||||
start()
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun close() {
|
|
||||||
socket.close()
|
|
||||||
thread.join(1_000L)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
private fun response(
|
|
||||||
status: Int,
|
|
||||||
body: ByteArray = byteArrayOf(),
|
|
||||||
headers: List<String> = emptyList()
|
|
||||||
): ByteArray {
|
|
||||||
val reason =
|
|
||||||
when (status) {
|
|
||||||
200 -> "OK"
|
|
||||||
206 -> "Partial Content"
|
|
||||||
else -> "Range Not Satisfiable"
|
|
||||||
}
|
|
||||||
val head = buildString {
|
|
||||||
append("HTTP/1.1 $status $reason\r\n")
|
|
||||||
headers.forEach { append("$it\r\n") }
|
|
||||||
append("Content-Length: ${body.size}\r\n")
|
|
||||||
append("Connection: close\r\n\r\n")
|
|
||||||
}.toByteArray()
|
|
||||||
return head + body
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user