Add AbsLogger to download paths

This commit is contained in:
Nicholas Wallace
2026-08-27 20:38:48 -07:00
parent ba66869502
commit a100226a85
7 changed files with 63 additions and 229 deletions
-4
View File
@@ -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)
} }
} }
@@ -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")
} }
} }
} }
@@ -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
} }
} }
@@ -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
}
}
}