Initial rewrite of 1587 by codex

This commit is contained in:
Nicholas Wallace
2026-07-18 17:32:37 -07:00
parent 185cba16eb
commit eb2483d039
12 changed files with 622 additions and 994 deletions
@@ -1,12 +1,14 @@
package com.audiobookshelf.app.data package com.audiobookshelf.app.data
import android.content.Context import android.content.Context
import android.net.Uri
import android.support.v4.media.MediaDescriptionCompat import android.support.v4.media.MediaDescriptionCompat
import android.util.Log import android.util.Log
import com.fasterxml.jackson.annotation.JsonIgnore import com.fasterxml.jackson.annotation.JsonIgnore
import com.fasterxml.jackson.annotation.JsonIgnoreProperties import com.fasterxml.jackson.annotation.JsonIgnoreProperties
import com.fasterxml.jackson.annotation.JsonSubTypes import com.fasterxml.jackson.annotation.JsonSubTypes
import com.fasterxml.jackson.annotation.JsonTypeInfo import com.fasterxml.jackson.annotation.JsonTypeInfo
import java.io.File
enum class LockOrientationSetting { enum class LockOrientationSetting {
NONE, PORTRAIT, LANDSCAPE NONE, PORTRAIT, LANDSCAPE
@@ -57,6 +59,19 @@ data class LocalFile(
var mimeType:String?, var mimeType:String?,
var size:Long var size:Long
) { ) {
@JsonIgnore
fun exists(ctx: Context): Boolean {
if (contentUrl.startsWith("content:")) {
return try {
ctx.contentResolver.openFileDescriptor(Uri.parse(contentUrl), "r")?.use { true } ?: false
} catch (e: Exception) {
Log.w("LocalFile", "Cannot access SAF file $contentUrl", e)
false
}
}
return File(absolutePath).exists()
}
@JsonIgnore @JsonIgnore
fun isAudioFile():Boolean { fun isAudioFile():Boolean {
if (mimeType == "application/octet-stream") return true if (mimeType == "application/octet-stream") return true
@@ -218,4 +233,3 @@ data class DeviceData(
} }
} }
} }
@@ -1,15 +0,0 @@
package com.audiobookshelf.app.data
data class FolderScanResult(
var itemsAdded:Int,
var itemsUpdated:Int,
var itemsRemoved:Int,
var itemsUpToDate:Int,
val localFolder:LocalFolder,
val localLibraryItems:List<LocalLibraryItem>,
)
data class LocalLibraryItemScanResult(
val updated:Boolean,
val localLibraryItem:LocalLibraryItem,
)
@@ -80,7 +80,7 @@ class LocalLibraryItem(
} }
@JsonIgnore @JsonIgnore
fun hasTracks(episode:PodcastEpisode?): Boolean { fun hasTracks(ctx: Context, episode:PodcastEpisode?): Boolean {
var audioTracks = media.getAudioTracks() as MutableList<AudioTrack> var audioTracks = media.getAudioTracks() as MutableList<AudioTrack>
if (episode != null) { // Get podcast episode audio track if (episode != null) { // Get podcast episode audio track
episode.audioTrack?.let { at -> mutableListOf(at) }?.let { tracks -> audioTracks = tracks } episode.audioTrack?.let { at -> mutableListOf(at) }?.let { tracks -> audioTracks = tracks }
@@ -91,15 +91,19 @@ class LocalLibraryItem(
if (it.metadata === null) { if (it.metadata === null) {
return false return false
} }
// Check that file exists if (!trackExists(ctx, it.contentUrl, it.metadata!!.path)) {
val file = File(it.metadata!!.path)
if (!file.exists()) {
return false return false
} }
} }
return true return true
} }
/** App-private files have paths; SAF files must be validated through their persisted URI. */
@JsonIgnore
private fun trackExists(ctx: Context, contentUrl: String?, path: String): Boolean {
return LocalFile("", null, contentUrl ?: "", "", path, "", null, 0).exists(ctx)
}
@JsonIgnore @JsonIgnore
fun getPlaybackSession(episode:PodcastEpisode?, deviceInfo:DeviceInfo):PlaybackSession { fun getPlaybackSession(episode:PodcastEpisode?, deviceInfo:DeviceInfo):PlaybackSession {
val localEpisodeId = episode?.id val localEpisodeId = episode?.id
@@ -7,570 +7,299 @@ import androidx.documentfile.provider.DocumentFile
import com.anggrayudi.storage.file.* import com.anggrayudi.storage.file.*
import com.audiobookshelf.app.data.* import com.audiobookshelf.app.data.*
import com.audiobookshelf.app.models.DownloadItem import com.audiobookshelf.app.models.DownloadItem
import com.fasterxml.jackson.core.json.JsonReadFeature import com.audiobookshelf.app.models.DownloadItemPart
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import java.io.File import java.io.File
class FolderScanner(var ctx: Context) { /** Creates local-library records from the completed download manifest, not a recursive rescan. */
class FolderScanner(private val ctx: Context) {
private val tag = "FolderScanner" private val tag = "FolderScanner"
private var jacksonMapper =
jacksonObjectMapper()
.enable(JsonReadFeature.ALLOW_UNESCAPED_CONTROL_CHARS.mappedFeature())
data class DownloadItemScanResult( data class DownloadItemScanResult(
val localLibraryItem: LocalLibraryItem, val localLibraryItem: LocalLibraryItem,
var localMediaProgress: LocalMediaProgress? var localMediaProgress: LocalMediaProgress?
) )
private fun getLocalLibraryItemId(mediaItemId: String): String { private fun localLibraryItemId(mediaItemId: String) = "local_${DeviceManager.getBase64Id(mediaItemId)}"
return "local_" + DeviceManager.getBase64Id(mediaItemId)
}
private fun scanInternalDownloadItem( private fun createLocalFile(part: DownloadItemPart, externalFile: DocumentFile? = null): LocalFile? {
downloadItem: DownloadItem, if (part.isInternalStorage) {
cb: (DownloadItemScanResult?) -> Unit val file = File(part.finalDestinationPath)
) { if (!file.exists()) return null
val localLibraryItemId = "local_${downloadItem.libraryItemId}" return LocalFile(
DeviceManager.getBase64Id(file.name),
var localEpisodeId: String? = null file.name,
var localLibraryItem: LocalLibraryItem? Uri.fromFile(file).toString(),
if (downloadItem.mediaType == "book") { file.getBasePath(ctx),
localLibraryItem = file.absolutePath,
LocalLibraryItem( file.getSimplePath(ctx),
localLibraryItemId, file.mimeType,
downloadItem.localFolder.id, file.length()
downloadItem.itemFolderPath, )
downloadItem.itemFolderPath,
"",
false,
downloadItem.mediaType,
downloadItem.media.getLocalCopy(),
mutableListOf(),
null,
null,
true,
downloadItem.serverConnectionConfigId,
downloadItem.serverAddress,
downloadItem.serverUserId,
downloadItem.libraryItemId
)
} else {
// Lookup or create podcast local library item
localLibraryItem = DeviceManager.dbManager.getLocalLibraryItem(localLibraryItemId)
if (localLibraryItem == null) {
Log.d(
tag,
"[FolderScanner] Podcast local library item not created yet for ${downloadItem.media.metadata.title}"
)
localLibraryItem =
LocalLibraryItem(
localLibraryItemId,
downloadItem.localFolder.id,
downloadItem.itemFolderPath,
downloadItem.itemFolderPath,
"",
false,
downloadItem.mediaType,
downloadItem.media.getLocalCopy(),
mutableListOf(),
null,
null,
true,
downloadItem.serverConnectionConfigId,
downloadItem.serverAddress,
downloadItem.serverUserId,
downloadItem.libraryItemId
)
}
} }
val audioTracks: MutableList<AudioTrack> = mutableListOf() part.completedDestinationUri?.let { contentUrl ->
var foundEBookFile = false val uri = Uri.parse(contentUrl)
val size =
downloadItem.downloadItemParts.forEach { downloadItemPart -> try {
Log.d( ctx.contentResolver.openFileDescriptor(uri, "r")?.use { descriptor ->
tag, descriptor.statSize.coerceAtLeast(0L)
"Scan internal storage item with finalDestinationUri=${downloadItemPart.finalDestinationUri}" } ?: 0L
} catch (e: Exception) {
Log.e(tag, "Could not open completed SAF file: $contentUrl", e)
return null
}
// Android 10 DownloadsProvider may not reconstruct a DocumentFile for an audio URI even
// though the URI remains readable. Keep the URI as the authoritative local-file location.
return LocalFile(
DeviceManager.getBase64Id(contentUrl),
part.filename,
contentUrl,
part.localFolderName,
part.finalDestinationPath,
part.finalDestinationPath,
mimeTypeFor(part),
size
) )
}
val file = File(downloadItemPart.finalDestinationPath) // Do not reconstruct a DocumentFile from an absolute path: on Android 10 that becomes a
Log.d(tag, "Scan internal storage item created file ${file.name}") // file:// URI, which DocumentsContract rejects. The caller resolves this from the persisted
// SAF tree grant instead.
val document = externalFile
if (document == null || !document.exists()) {
Log.e(tag, "Could not resolve downloaded SAF file: ${part.finalDestinationPath}")
return null
}
return LocalFile(
DeviceManager.getBase64Id(document.id),
document.name,
document.uri.toString(),
document.getBasePath(ctx),
document.getAbsolutePath(ctx),
document.getSimplePath(ctx),
document.mimeType,
document.length()
)
}
if (file == null) { private fun newLocalLibraryItem(
Log.e( id: String,
tag, downloadItem: DownloadItem,
"scanInternalDownloadItem: Null docFile for path ${downloadItemPart.finalDestinationPath}" basePath: String,
) absolutePath: String,
} else { contentUrl: String
if (downloadItemPart.audioTrack != null) { ) =
val audioTrackFromServer = downloadItemPart.audioTrack LocalLibraryItem(
Log.d( id,
tag, downloadItem.localFolder.id,
"scanInternalDownloadItem: Audio Track from Server index = ${audioTrackFromServer.index}" basePath,
absolutePath,
contentUrl,
false,
downloadItem.mediaType,
downloadItem.media.getLocalCopy(),
mutableListOf(),
null,
null,
true,
downloadItem.serverConnectionConfigId,
downloadItem.serverAddress,
downloadItem.serverUserId,
downloadItem.libraryItemId
) )
val localFileId = DeviceManager.getBase64Id(file.name) private fun scanParts(
Log.d(tag, "Scan internal file localFileId=$localFileId") item: DownloadItem,
val localFile = localItem: LocalLibraryItem,
LocalFile( externalFolder: DocumentFile? = null,
localFileId, callback: (DownloadItemScanResult?) -> Unit
file.name, ) {
downloadItemPart.finalDestinationUri.toString(), val tracks = mutableListOf<AudioTrack>()
file.getBasePath(ctx), var foundEbook = false
file.absolutePath, var localEpisodeId: String? = null
file.getSimplePath(ctx),
file.mimeType,
file.length()
)
localLibraryItem.localFiles.add(localFile)
val trackFileMetadata = item.downloadItemParts.forEach { part ->
val externalFile =
if (part.isInternalStorage) {
null
} else {
part.completedDestinationUri
?.let { DocumentFileCompat.fromUri(ctx, Uri.parse(it)) }
?: resolveExternalFile(externalFolder, part)
}
Log.d(tag, "Resolve part ${part.filename}: externalFile=${externalFile?.uri}")
val localFile = createLocalFile(part, externalFile) ?: return@forEach
when {
part.audioTrack != null -> {
val serverTrack = part.audioTrack
localItem.localFiles.removeAll { it.id == localFile.id }
localItem.localFiles.add(localFile)
val metadata =
FileMetadata( FileMetadata(
file.name, localFile.filename ?: "",
file.extension, File(localFile.filename ?: "").extension,
file.absolutePath, localFile.absolutePath,
file.getBasePath(ctx), localFile.basePath,
file.length() localFile.size
) )
// Create new audio track
val track = val track =
AudioTrack( AudioTrack(
audioTrackFromServer.index, serverTrack.index,
audioTrackFromServer.startOffset, serverTrack.startOffset,
audioTrackFromServer.duration, serverTrack.duration,
localFile.filename ?: "", localFile.filename ?: "",
localFile.contentUrl, localFile.contentUrl,
localFile.mimeType ?: "", localFile.mimeType ?: "",
trackFileMetadata, metadata,
true, true,
localFileId, localFile.id,
audioTrackFromServer.index serverTrack.index
) )
audioTracks.add(track) tracks.add(track)
Log.d(tag, "Added local audio track ${track.contentUrl} (${track.metadata?.path})")
Log.d( part.episode?.let { episode ->
tag, val podcast = localItem.media as Podcast
"scanInternalDownloadItem: Created Audio Track with index ${track.index} from local file ${localFile.absolutePath}" localEpisodeId = podcast.addEpisode(track, episode).id
)
// Add podcast episodes to library
downloadItemPart.episode?.let { podcastEpisode ->
val podcast = localLibraryItem.media as Podcast
val newEpisode = podcast.addEpisode(track, podcastEpisode)
localEpisodeId = newEpisode.id
Log.d(
tag,
"scanInternalDownloadItem: Added episode to podcast ${podcastEpisode.title} ${track.title} | Track index: ${podcastEpisode.audioTrack?.index}"
)
} }
} else if (downloadItemPart.ebookFile != null) { }
foundEBookFile = true part.ebookFile != null -> {
Log.d(tag, "scanInternalDownloadItem: Ebook file found with mimetype=${file.mimeType}") foundEbook = true
val localFileId = DeviceManager.getBase64Id(file.name) localItem.localFiles.removeAll { it.id == localFile.id }
val localFile = localItem.localFiles.add(localFile)
LocalFile( (localItem.media as Book).ebookFile =
localFileId,
file.name,
Uri.fromFile(file).toString(),
file.getBasePath(ctx),
file.absolutePath,
file.getSimplePath(ctx),
file.mimeType,
file.length()
)
localLibraryItem.localFiles.add(localFile)
val ebookFile =
EBookFile( EBookFile(
downloadItemPart.ebookFile.ino, part.ebookFile.ino,
downloadItemPart.ebookFile.metadata, part.ebookFile.metadata,
downloadItemPart.ebookFile.ebookFormat, part.ebookFile.ebookFormat,
true, true,
localFileId, localFile.id,
localFile.contentUrl localFile.contentUrl
) )
(localLibraryItem.media as Book).ebookFile = ebookFile }
Log.d(tag, "scanInternalDownloadItem: Ebook file added to lli ${localFile.contentUrl}") else -> {
} else { localItem.coverAbsolutePath = localFile.absolutePath
val localFileId = DeviceManager.getBase64Id(file.name) localItem.coverContentUrl = localFile.contentUrl
val localFile = localItem.localFiles.removeAll { it.id == localFile.id }
LocalFile( localItem.localFiles.add(localFile)
localFileId,
file.name,
Uri.fromFile(file).toString(),
file.getBasePath(ctx),
file.absolutePath,
file.getSimplePath(ctx),
file.mimeType,
file.length()
)
localLibraryItem.coverAbsolutePath = localFile.absolutePath
localLibraryItem.coverContentUrl = localFile.contentUrl
localLibraryItem.localFiles.add(localFile)
} }
} }
} }
if (audioTracks.isEmpty() && !foundEBookFile) { if (tracks.isEmpty() && !foundEbook) {
Log.d( callback(null)
tag, return
"scanDownloadItem did not find any audio tracks or ebook file in folder for ${downloadItem.itemFolderPath}"
)
return cb(null)
} }
if (item.mediaType == "book") {
// For books sort audio tracks then set tracks.sortBy { it.index }
if (downloadItem.mediaType == "book") { var expectedIndex = 1
audioTracks.sortBy { it.index } var offset = 0.0
tracks.forEach { track ->
var indexCheck = 1 track.index = expectedIndex++
var startOffset = 0.0 track.startOffset = offset
audioTracks.forEach { audioTrack -> offset += track.duration
if (audioTrack.index != indexCheck || audioTrack.startOffset != startOffset) {
audioTrack.index = indexCheck
audioTrack.startOffset = startOffset
}
indexCheck++
startOffset += audioTrack.duration
} }
localItem.media.setAudioTracks(tracks)
localLibraryItem.media.setAudioTracks(audioTracks)
} }
val downloadItemScanResult = DownloadItemScanResult(localLibraryItem, null) val result = DownloadItemScanResult(localItem, null)
item.userMediaProgress?.let { progress ->
// If library item had media progress then make local media progress and save val progressId = if (item.episodeId.isNullOrEmpty()) localItem.id else "${localItem.id}-$localEpisodeId"
downloadItem.userMediaProgress?.let { mediaProgress -> result.localMediaProgress =
val localMediaProgressId =
if (downloadItem.episodeId.isNullOrEmpty()) localLibraryItemId
else "$localLibraryItemId-$localEpisodeId"
val newLocalMediaProgress =
LocalMediaProgress( LocalMediaProgress(
id = localMediaProgressId, progressId,
localLibraryItemId = localLibraryItemId, localItem.id,
localEpisodeId = localEpisodeId, localEpisodeId,
duration = mediaProgress.duration, progress.duration,
progress = mediaProgress.progress, progress.progress,
currentTime = mediaProgress.currentTime, progress.currentTime,
isFinished = mediaProgress.isFinished, progress.isFinished,
ebookLocation = mediaProgress.ebookLocation, progress.ebookLocation,
ebookProgress = mediaProgress.ebookProgress, progress.ebookProgress,
lastUpdate = mediaProgress.lastUpdate, progress.lastUpdate,
startedAt = mediaProgress.startedAt, progress.startedAt,
finishedAt = mediaProgress.finishedAt, progress.finishedAt,
serverConnectionConfigId = downloadItem.serverConnectionConfigId, item.serverConnectionConfigId,
serverAddress = downloadItem.serverAddress, item.serverAddress,
serverUserId = downloadItem.serverUserId, item.serverUserId,
libraryItemId = downloadItem.libraryItemId, item.libraryItemId,
episodeId = downloadItem.episodeId item.episodeId
) )
Log.d( DeviceManager.dbManager.saveLocalMediaProgress(result.localMediaProgress!!)
tag,
"scanLibraryItemFolder: Saving local media progress ${newLocalMediaProgress.id} at progress ${newLocalMediaProgress.progress}"
)
DeviceManager.dbManager.saveLocalMediaProgress(newLocalMediaProgress)
downloadItemScanResult.localMediaProgress = newLocalMediaProgress
} }
DeviceManager.dbManager.saveLocalLibraryItem(localItem)
DeviceManager.dbManager.saveLocalLibraryItem(localLibraryItem) callback(result)
cb(downloadItemScanResult)
} }
// Scan item after download and create local library item private fun findFolderByPath(root: DocumentFile, subPath: String): DocumentFile? {
fun scanDownloadItem(downloadItem: DownloadItem, cb: (DownloadItemScanResult?) -> Unit) { if (subPath.isBlank()) return root
// If downloading to internal storage handle separately var current = root
if (downloadItem.isInternalStorage) { subPath.split('/').filter { it.isNotBlank() }.forEach { segment ->
scanInternalDownloadItem(downloadItem, cb) if (segment == "." || segment == "..") return null
current = current.findFile(segment) ?: return null
}
return current
}
/**
* DownloadsProvider on Android 10 may expose an audio document without its extension through
* DocumentFile.findFile(). Match the manifest first, then match the provider-normalized base
* filename. MIME type and server-reported size are unreliable for Opus on this platform.
*/
private fun resolveExternalFile(folder: DocumentFile?, part: DownloadItemPart): DocumentFile? {
if (folder == null) return null
folder.findFile(part.filename)?.let { return it }
val expectedBaseName = part.filename.substringBeforeLast('.')
return folder.listFiles().firstOrNull { document ->
document.name == part.filename ||
document.fullName == part.filename ||
(part.audioTrack != null && document.isFile &&
(document.name ?: "").substringBeforeLast('.') == expectedBaseName) ||
(part.audioTrack != null && document.isFile &&
document.fullName.substringBeforeLast('.') == expectedBaseName)
}
}
private fun mimeTypeFor(part: DownloadItemPart): String? {
return part.audioTrack?.mimeType
?: when (part.ebookFile?.ebookFormat?.lowercase()) {
"epub" -> "application/epub+zip"
"pdf" -> "application/pdf"
else -> "image/jpeg"
}
}
fun scanDownloadItem(item: DownloadItem, callback: (DownloadItemScanResult?) -> Unit) {
if (item.isInternalStorage) {
val id = "local_${item.libraryItemId}"
val localItem =
DeviceManager.dbManager.getLocalLibraryItem(id)
?: newLocalLibraryItem(id, item, item.itemFolderPath, item.itemFolderPath, "")
scanParts(item, localItem, callback = callback)
return return
} }
val folderDf = DocumentFileCompat.fromUri(ctx, Uri.parse(downloadItem.localFolder.contentUrl)) val root = DocumentFileCompat.fromUri(ctx, Uri.parse(item.localFolder.contentUrl))
val foldersFound = folderDf?.search(true, DocumentFileType.FOLDER) ?: mutableListOf() if (root == null) {
Log.e(tag, "Invalid SAF root: ${item.localFolder.contentUrl}")
var itemFolderId = "" callback(null)
var itemFolderUrl = "" return
var itemFolderBasePath = ""
var itemFolderAbsolutePath = ""
foldersFound.forEach {
// e.g. absolute path is "storage/emulated/0/Audiobooks/Orson Scott Card/Enders Game"
// and itemSubfolder is "Orson Scott Card/Enders Game"
if (it.getAbsolutePath(ctx).endsWith(downloadItem.itemSubfolder)) {
itemFolderId = it.id
itemFolderUrl = it.uri.toString()
itemFolderBasePath = it.getBasePath(ctx)
itemFolderAbsolutePath = it.getAbsolutePath(ctx)
}
} }
val itemFolder = findFolderByPath(root, item.itemSubfolder)
if (itemFolderUrl == "") { if (itemFolder == null) {
Log.d(tag, "scanDownloadItem failed to find media folder") Log.e(tag, "SAF item folder not found: ${item.itemSubfolder}")
return cb(null) callback(null)
return
} }
val df: DocumentFile? = DocumentFileCompat.fromUri(ctx, Uri.parse(itemFolderUrl)) val id = localLibraryItemId(itemFolder.id)
val localItem =
if (df == null) { DeviceManager.dbManager.getLocalLibraryItem(id)
Log.e(tag, "Folder Doc File Invalid ${downloadItem.itemFolderPath}") ?: newLocalLibraryItem(
return cb(null) id,
} item,
itemFolder.getBasePath(ctx),
val localLibraryItemId = getLocalLibraryItemId(itemFolderId) itemFolder.getAbsolutePath(ctx),
Log.d( itemFolder.uri.toString()
tag, )
"scanDownloadItem starting for ${downloadItem.itemFolderPath} | ${df.uri} | Item Folder Id:$itemFolderId | LLI Id:$localLibraryItemId" scanParts(item, localItem, itemFolder, callback)
)
// Search for files in media item folder
// m4b files showing as mimeType application/octet-stream on Android 10 and earlier see #154
val filesFound =
df.search(
false,
DocumentFileType.FILE,
arrayOf("audio/*", "image/*", "video/mp4", "application/*")
)
Log.d(tag, "scanDownloadItem ${filesFound.size} files found in ${downloadItem.itemFolderPath}")
var localEpisodeId: String? = null
var localLibraryItem: LocalLibraryItem?
if (downloadItem.mediaType == "book") {
localLibraryItem =
LocalLibraryItem(
localLibraryItemId,
downloadItem.localFolder.id,
itemFolderBasePath,
itemFolderAbsolutePath,
itemFolderUrl,
false,
downloadItem.mediaType,
downloadItem.media.getLocalCopy(),
mutableListOf(),
null,
null,
true,
downloadItem.serverConnectionConfigId,
downloadItem.serverAddress,
downloadItem.serverUserId,
downloadItem.libraryItemId
)
} else {
// Lookup or create podcast local library item
localLibraryItem = DeviceManager.dbManager.getLocalLibraryItem(localLibraryItemId)
if (localLibraryItem == null) {
Log.d(
tag,
"[FolderScanner] Podcast local library item not created yet for ${downloadItem.media.metadata.title}"
)
localLibraryItem =
LocalLibraryItem(
localLibraryItemId,
downloadItem.localFolder.id,
itemFolderBasePath,
itemFolderAbsolutePath,
itemFolderUrl,
false,
downloadItem.mediaType,
downloadItem.media.getLocalCopy(),
mutableListOf(),
null,
null,
true,
downloadItem.serverConnectionConfigId,
downloadItem.serverAddress,
downloadItem.serverUserId,
downloadItem.libraryItemId
)
}
}
val audioTracks: MutableList<AudioTrack> = mutableListOf()
var foundEBookFile = false
filesFound.forEach { docFile ->
val itemPart =
downloadItem.downloadItemParts.find { itemPart -> itemPart.filename == docFile.name }
if (itemPart == null) {
if (downloadItem.mediaType == "book"
) { // for books every download item should be a file found
Log.e(
tag,
"scanDownloadItem: Item part not found for doc file ${docFile.name} | ${docFile.getAbsolutePath(ctx)} | ${docFile.uri}"
)
}
} else if (itemPart.audioTrack != null) { // Is audio track
val audioTrackFromServer = itemPart.audioTrack
Log.d(
tag,
"scanDownloadItem: Audio Track from Server index = ${audioTrackFromServer.index}"
)
val localFileId = DeviceManager.getBase64Id(docFile.id)
val localFile =
LocalFile(
localFileId,
docFile.name,
docFile.uri.toString(),
docFile.getBasePath(ctx),
docFile.getAbsolutePath(ctx),
docFile.getSimplePath(ctx),
docFile.mimeType,
docFile.length()
)
localLibraryItem.localFiles.add(localFile)
// Create new audio track
val trackFileMetadata =
FileMetadata(
docFile.name ?: "",
docFile.extension ?: "",
docFile.getAbsolutePath(ctx),
docFile.getBasePath(ctx),
docFile.length()
)
val track =
AudioTrack(
audioTrackFromServer.index,
audioTrackFromServer.startOffset,
audioTrackFromServer.duration,
localFile.filename ?: "",
localFile.contentUrl,
localFile.mimeType ?: "",
trackFileMetadata,
true,
localFileId,
audioTrackFromServer.index
)
audioTracks.add(track)
Log.d(
tag,
"scanDownloadItem: Created Audio Track with index ${track.index} from local file ${localFile.absolutePath}"
)
// Add podcast episodes to library
itemPart.episode?.let { podcastEpisode ->
val podcast = localLibraryItem.media as Podcast
val newEpisode = podcast.addEpisode(track, podcastEpisode)
localEpisodeId = newEpisode.id
Log.d(
tag,
"scanDownloadItem: Added episode to podcast ${podcastEpisode.title} ${track.title} | Track index: ${podcastEpisode.audioTrack?.index}"
)
}
} else if (itemPart.ebookFile != null) { // Ebook
foundEBookFile = true
Log.d(tag, "scanDownloadItem: Ebook file found with mimetype=${docFile.mimeType}")
val localFileId = DeviceManager.getBase64Id(docFile.id)
val localFile =
LocalFile(
localFileId,
docFile.name,
docFile.uri.toString(),
docFile.getBasePath(ctx),
docFile.getAbsolutePath(ctx),
docFile.getSimplePath(ctx),
docFile.mimeType,
docFile.length()
)
localLibraryItem.localFiles.add(localFile)
val ebookFile =
EBookFile(
itemPart.ebookFile.ino,
itemPart.ebookFile.metadata,
itemPart.ebookFile.ebookFormat,
true,
localFileId,
localFile.contentUrl
)
(localLibraryItem.media as Book).ebookFile = ebookFile
Log.d(tag, "scanDownloadItem: Ebook file added to lli ${localFile.contentUrl}")
} else { // Cover image
val localFileId = DeviceManager.getBase64Id(docFile.id)
val localFile =
LocalFile(
localFileId,
docFile.name,
docFile.uri.toString(),
docFile.getBasePath(ctx),
docFile.getAbsolutePath(ctx),
docFile.getSimplePath(ctx),
docFile.mimeType,
docFile.length()
)
localLibraryItem.coverAbsolutePath = localFile.absolutePath
localLibraryItem.coverContentUrl = localFile.contentUrl
localLibraryItem.localFiles.add(localFile)
}
}
if (audioTracks.isEmpty() && !foundEBookFile) {
Log.d(
tag,
"scanDownloadItem did not find any audio tracks or ebook file in folder for ${downloadItem.itemFolderPath}"
)
return cb(null)
}
// For books sort audio tracks then set
if (downloadItem.mediaType == "book") {
audioTracks.sortBy { it.index }
var indexCheck = 1
var startOffset = 0.0
audioTracks.forEach { audioTrack ->
if (audioTrack.index != indexCheck || audioTrack.startOffset != startOffset) {
audioTrack.index = indexCheck
audioTrack.startOffset = startOffset
}
indexCheck++
startOffset += audioTrack.duration
}
localLibraryItem.media.setAudioTracks(audioTracks)
}
val downloadItemScanResult = DownloadItemScanResult(localLibraryItem, null)
// If library item had media progress then make local media progress and save
downloadItem.userMediaProgress?.let { mediaProgress ->
val localMediaProgressId =
if (downloadItem.episodeId.isNullOrEmpty()) localLibraryItemId
else "$localLibraryItemId-$localEpisodeId"
val newLocalMediaProgress =
LocalMediaProgress(
id = localMediaProgressId,
localLibraryItemId = localLibraryItemId,
localEpisodeId = localEpisodeId,
duration = mediaProgress.duration,
progress = mediaProgress.progress,
currentTime = mediaProgress.currentTime,
isFinished = mediaProgress.isFinished,
ebookLocation = mediaProgress.ebookLocation,
ebookProgress = mediaProgress.ebookProgress,
lastUpdate = mediaProgress.lastUpdate,
startedAt = mediaProgress.startedAt,
finishedAt = mediaProgress.finishedAt,
serverConnectionConfigId = downloadItem.serverConnectionConfigId,
serverAddress = downloadItem.serverAddress,
serverUserId = downloadItem.serverUserId,
libraryItemId = downloadItem.libraryItemId,
episodeId = downloadItem.episodeId
)
Log.d(
tag,
"scanLibraryItemFolder: Saving local media progress ${newLocalMediaProgress.id} at progress ${newLocalMediaProgress.progress}"
)
DeviceManager.dbManager.saveLocalMediaProgress(newLocalMediaProgress)
downloadItemScanResult.localMediaProgress = newLocalMediaProgress
}
DeviceManager.dbManager.saveLocalLibraryItem(localLibraryItem)
cb(downloadItemScanResult)
} }
} }
@@ -122,6 +122,21 @@ class DbManager {
return downloadItems return downloadItems
} }
/**
* The downloader now persists app-owned staging paths instead of DownloadManager state. Old
* queue entries cannot be resumed safely, but completed local media lives in other books and is
* deliberately left alone.
*/
fun clearLegacyDownloadQueueOnce() {
val metadata = Paper.book("downloadQueueMetadata")
val architectureVersion = metadata.read<Int>("architectureVersion") ?: 0
if (architectureVersion >= 2) return
Paper.book("downloadItems").destroy()
metadata.write("architectureVersion", 2)
Log.i(tag, "Cleared legacy persisted download queue for architecture v2")
}
fun saveLocalMediaProgress(mediaProgress: LocalMediaProgress) { fun saveLocalMediaProgress(mediaProgress: LocalMediaProgress) {
Paper.book("localMediaProgress").write(mediaProgress.id, mediaProgress) Paper.book("localMediaProgress").write(mediaProgress.id, mediaProgress)
} }
@@ -148,7 +163,7 @@ class DbManager {
} }
// Make sure all local file ids still exist // Make sure all local file ids still exist
fun cleanLocalLibraryItems() { fun cleanLocalLibraryItems(context: Context) {
val localLibraryItems = getLocalLibraryItems() val localLibraryItems = getLocalLibraryItems()
localLibraryItems.forEach { lli -> localLibraryItems.forEach { lli ->
@@ -157,15 +172,15 @@ class DbManager {
// Check local files // Check local files
lli.localFiles = lli.localFiles =
lli.localFiles.filter { localFile -> lli.localFiles.filter { localFile ->
val file = File(localFile.absolutePath) val exists = localFile.exists(context)
if (!file.exists()) { if (!exists) {
Log.d( Log.d(
tag, tag,
"cleanLocalLibraryItems: Local file ${localFile.absolutePath} was removed from library item ${lli.media.metadata.title}" "cleanLocalLibraryItems: Local file ${localFile.absolutePath} was removed from library item ${lli.media.metadata.title}"
) )
hasUpdates = true hasUpdates = true
} }
file.exists() exists
} as } as
MutableList<LocalFile> MutableList<LocalFile>
@@ -203,9 +218,10 @@ class DbManager {
// Check cover still there // Check cover still there
lli.coverAbsolutePath?.let { lli.coverAbsolutePath?.let {
val coverFile = File(it) val coverExists = lli.localFiles.any { localFile ->
localFile.absolutePath == it && localFile.exists(context)
if (!coverFile.exists()) { }
if (!coverExists) {
Log.d( Log.d(
tag, tag,
"cleanLocalLibraryItems: Cover $it was removed from library item ${lli.media.metadata.title}" "cleanLocalLibraryItems: Cover $it was removed from library item ${lli.media.metadata.title}"
@@ -1,15 +1,8 @@
package com.audiobookshelf.app.managers package com.audiobookshelf.app.managers
import android.app.DownloadManager
import android.net.Uri import android.net.Uri
import android.util.Log import android.util.Log
import androidx.documentfile.provider.DocumentFile import androidx.documentfile.provider.DocumentFile
import com.anggrayudi.storage.callback.FileCallback
import com.anggrayudi.storage.file.DocumentFileCompat
import com.anggrayudi.storage.file.MimeType
import com.anggrayudi.storage.file.getAbsolutePath
import com.anggrayudi.storage.file.moveFileTo
import com.anggrayudi.storage.media.FileDescription
import com.audiobookshelf.app.MainActivity 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
@@ -19,36 +12,32 @@ 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
import java.io.File import java.io.File
import java.io.FileOutputStream import java.io.FileInputStream
import java.util.* import java.util.concurrent.ConcurrentHashMap
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import okhttp3.Call
/** Manages download items and their parts. */ /** Owns the Android download queue and writes all bytes to app-owned staging files. */
class DownloadItemManager( class DownloadItemManager(
var downloadManager: DownloadManager, private val folderScanner: FolderScanner,
private var folderScanner: FolderScanner, private val mainActivity: MainActivity,
var mainActivity: MainActivity, private val clientEventEmitter: DownloadEventEmitter
private var clientEventEmitter: DownloadEventEmitter
) { ) {
val tag = "DownloadItemManager" private val tag = "DownloadItemManager"
private val maxSimultaneousDownloads = 3 private val maxSimultaneousDownloads = 3
private var jacksonMapper = private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
jacksonObjectMapper() private val activeCalls = ConcurrentHashMap<String, Call>()
.enable(JsonReadFeature.ALLOW_UNESCAPED_CONTROL_CHARS.mappedFeature()) private var watcherRunning = false
private val jacksonMapper =
jacksonObjectMapper().enable(JsonReadFeature.ALLOW_UNESCAPED_CONTROL_CHARS.mappedFeature())
enum class DownloadCheckStatus { var downloadItemQueue: MutableList<DownloadItem> = mutableListOf()
InProgress, var currentDownloadItemParts: MutableList<DownloadItemPart> = mutableListOf()
Successful,
Failed
}
var downloadItemQueue: MutableList<DownloadItem> =
mutableListOf() // All pending and downloading items
var currentDownloadItemParts: MutableList<DownloadItemPart> =
mutableListOf() // Item parts currently being downloaded
interface DownloadEventEmitter { interface DownloadEventEmitter {
fun onDownloadItem(downloadItem: DownloadItem) fun onDownloadItem(downloadItem: DownloadItem)
@@ -61,323 +50,223 @@ class DownloadItemManager(
fun onComplete(failed: Boolean) fun onComplete(failed: Boolean)
} }
companion object { init {
var isDownloading: Boolean = false DeviceManager.dbManager.clearLegacyDownloadQueueOnce()
} }
/** Adds a download item to the queue and starts processing the queue. */ @Synchronized
fun addDownloadItem(downloadItem: DownloadItem) { fun addDownloadItem(downloadItem: DownloadItem) {
DeviceManager.dbManager.saveDownloadItem(downloadItem) DeviceManager.dbManager.saveDownloadItem(downloadItem)
Log.i(tag, "Add download item ${downloadItem.media.metadata.title}")
downloadItemQueue.add(downloadItem) downloadItemQueue.add(downloadItem)
clientEventEmitter.onDownloadItem(downloadItem) clientEventEmitter.onDownloadItem(downloadItem)
checkUpdateDownloadQueue() checkUpdateDownloadQueue()
} }
/** Checks and updates the download queue. */ @Synchronized
private fun checkUpdateDownloadQueue() { private fun checkUpdateDownloadQueue() {
for (downloadItem in downloadItemQueue) { for (downloadItem in downloadItemQueue.toList()) {
val numPartsToGet = maxSimultaneousDownloads - currentDownloadItemParts.size val availableSlots = maxSimultaneousDownloads - currentDownloadItemParts.size
val nextDownloadItemParts = downloadItem.getNextDownloadItemParts(numPartsToGet) if (availableSlots <= 0) break
Log.d( downloadItem.getNextDownloadItemParts(availableSlots).forEach(::startDownload)
tag,
"checkUpdateDownloadQueue: numPartsToGet=$numPartsToGet, nextDownloadItemParts=${nextDownloadItemParts.size}"
)
if (nextDownloadItemParts.isNotEmpty()) {
processDownloadItemParts(nextDownloadItemParts)
}
if (currentDownloadItemParts.size >= maxSimultaneousDownloads) {
break
}
} }
if (currentDownloadItemParts.isNotEmpty()) startWatchingDownloads() if (currentDownloadItemParts.isNotEmpty()) startWatchingDownloads()
} }
/** Processes the download item parts. */ private fun startDownload(part: DownloadItemPart) {
private fun processDownloadItemParts(nextDownloadItemParts: List<DownloadItemPart>) { val stagingFile = File(part.destinationPath)
nextDownloadItemParts.forEach { stagingFile.parentFile?.mkdirs()
if (it.isInternalStorage) { part.downloadId = APP_MANAGED_DOWNLOAD_ID
startInternalDownload(it) part.lastUpdateTime = System.currentTimeMillis()
} else { currentDownloadItemParts.add(part)
startExternalDownload(it) val callback =
}
}
}
/** Starts an internal download. */
private fun startInternalDownload(downloadItemPart: DownloadItemPart) {
val file = File(downloadItemPart.finalDestinationPath)
file.parentFile?.mkdirs()
val fileOutputStream = FileOutputStream(downloadItemPart.finalDestinationPath)
val internalProgressCallback =
object : InternalProgressCallback { object : InternalProgressCallback {
override fun onProgress(totalBytesWritten: Long, progress: Long) { override fun onProgress(totalBytesWritten: Long, progress: Long) {
downloadItemPart.bytesDownloaded = totalBytesWritten synchronized(this@DownloadItemManager) {
downloadItemPart.progress = progress part.bytesDownloaded = totalBytesWritten
part.progress = progress
part.lastUpdateTime = System.currentTimeMillis()
}
} }
override fun onComplete(failed: Boolean) { override fun onComplete(failed: Boolean) {
downloadItemPart.failed = failed synchronized(this@DownloadItemManager) {
downloadItemPart.completed = true part.failed = failed
} part.completed = true
} part.lastUpdateTime = System.currentTimeMillis()
activeCalls.remove(part.id)
Log.d(
tag,
"Start internal download to destination path ${downloadItemPart.finalDestinationPath} from ${downloadItemPart.serverUrl}"
)
InternalDownloadManager(fileOutputStream, internalProgressCallback)
.download(downloadItemPart.serverUrl)
downloadItemPart.downloadId = 1
currentDownloadItemParts.add(downloadItemPart)
}
/** Starts an external download. */
private fun startExternalDownload(downloadItemPart: DownloadItemPart) {
val dlRequest = downloadItemPart.getDownloadRequest()
val downloadId = downloadManager.enqueue(dlRequest)
downloadItemPart.downloadId = downloadId
Log.d(tag, "checkUpdateDownloadQueue: Starting download item part, downloadId=$downloadId")
currentDownloadItemParts.add(downloadItemPart)
}
/** Starts watching the downloads. */
private fun startWatchingDownloads() {
if (isDownloading) return // Already watching
GlobalScope.launch(Dispatchers.IO) {
Log.d(tag, "Starting watching downloads")
isDownloading = true
while (currentDownloadItemParts.isNotEmpty()) {
val itemParts = currentDownloadItemParts.filter { !it.isMoving }
for (downloadItemPart in itemParts) {
if (downloadItemPart.isInternalStorage) {
handleInternalDownloadPart(downloadItemPart)
} else {
handleExternalDownloadPart(downloadItemPart)
}
}
delay(500)
if (currentDownloadItemParts.size < maxSimultaneousDownloads) {
checkUpdateDownloadQueue()
}
}
Log.d(tag, "Finished watching downloads")
isDownloading = false
}
}
/** Handles an internal download part. */
private fun handleInternalDownloadPart(downloadItemPart: DownloadItemPart) {
clientEventEmitter.onDownloadItemPartUpdate(downloadItemPart)
if (downloadItemPart.completed) {
val downloadItem = downloadItemQueue.find { it.id == downloadItemPart.downloadItemId }
downloadItem?.let { checkDownloadItemFinished(it) }
currentDownloadItemParts.remove(downloadItemPart)
}
}
/** Handles an external download part. */
private fun handleExternalDownloadPart(downloadItemPart: DownloadItemPart) {
val downloadCheckStatus = checkDownloadItemPart(downloadItemPart)
clientEventEmitter.onDownloadItemPartUpdate(downloadItemPart)
// Will move to final destination, remove current item parts, and check if download item is
// finished
handleDownloadItemPartCheck(downloadCheckStatus, downloadItemPart)
}
/** Checks the status of a download item part. */
private fun checkDownloadItemPart(downloadItemPart: DownloadItemPart): DownloadCheckStatus {
val downloadId = downloadItemPart.downloadId ?: return DownloadCheckStatus.Failed
val query = DownloadManager.Query().setFilterById(downloadId)
downloadManager.query(query).use {
if (it.moveToFirst()) {
val bytesColumnIndex = it.getColumnIndex(DownloadManager.COLUMN_TOTAL_SIZE_BYTES)
val statusColumnIndex = it.getColumnIndex(DownloadManager.COLUMN_STATUS)
val bytesDownloadedColumnIndex =
it.getColumnIndex(DownloadManager.COLUMN_BYTES_DOWNLOADED_SO_FAR)
val totalBytes = if (bytesColumnIndex >= 0) it.getInt(bytesColumnIndex) else 0
val downloadStatus = if (statusColumnIndex >= 0) it.getInt(statusColumnIndex) else 0
val bytesDownloadedSoFar =
if (bytesDownloadedColumnIndex >= 0) it.getLong(bytesDownloadedColumnIndex) else 0
Log.d(
tag,
"checkDownloads Download ${downloadItemPart.filename} bytes $totalBytes | bytes dled $bytesDownloadedSoFar | downloadStatus $downloadStatus"
)
return when (downloadStatus) {
DownloadManager.STATUS_SUCCESSFUL -> {
Log.d(tag, "checkDownloads Download ${downloadItemPart.filename} Successful")
downloadItemPart.completed = true
downloadItemPart.progress = 1
downloadItemPart.bytesDownloaded = bytesDownloadedSoFar
DownloadCheckStatus.Successful
}
DownloadManager.STATUS_FAILED -> {
Log.d(tag, "checkDownloads Download ${downloadItemPart.filename} Failed")
downloadItemPart.completed = true
downloadItemPart.failed = true
DownloadCheckStatus.Failed
}
else -> {
val percentProgress =
if (totalBytes > 0) ((bytesDownloadedSoFar * 100L) / totalBytes) else 0
Log.d(
tag,
"checkDownloads Download ${downloadItemPart.filename} Progress = $percentProgress%"
)
downloadItemPart.progress = percentProgress
downloadItemPart.bytesDownloaded = bytesDownloadedSoFar
DownloadCheckStatus.InProgress
}
}
} else {
Log.d(tag, "Download ${downloadItemPart.filename} not found in dlmanager")
downloadItemPart.completed = true
downloadItemPart.failed = true
return DownloadCheckStatus.Failed
}
}
}
/** Handles the result of a download item part check. */
private fun handleDownloadItemPartCheck(
downloadCheckStatus: DownloadCheckStatus,
downloadItemPart: DownloadItemPart
) {
val downloadItem = downloadItemQueue.find { it.id == downloadItemPart.downloadItemId }
if (downloadItem == null) {
Log.e(
tag,
"Download item part finished but download item not found ${downloadItemPart.filename}"
)
currentDownloadItemParts.remove(downloadItemPart)
} else if (downloadCheckStatus == DownloadCheckStatus.Successful) {
moveDownloadedFile(downloadItem, downloadItemPart)
} else if (downloadCheckStatus != DownloadCheckStatus.InProgress) {
checkDownloadItemFinished(downloadItem)
currentDownloadItemParts.remove(downloadItemPart)
}
}
/** Moves the downloaded file to its final destination. */
private fun moveDownloadedFile(downloadItem: DownloadItem, downloadItemPart: DownloadItemPart) {
val file = DocumentFileCompat.fromUri(mainActivity, downloadItemPart.destinationUri)
Log.d(tag, "DOWNLOAD: DESTINATION URI ${downloadItemPart.destinationUri}")
val fcb =
object : FileCallback() {
override fun onPrepare() {
Log.d(tag, "DOWNLOAD: PREPARING MOVE FILE")
}
override fun onFailed(errorCode: ErrorCode) {
Log.e(tag, "DOWNLOAD: FAILED TO MOVE FILE $errorCode")
downloadItemPart.failed = true
downloadItemPart.isMoving = false
file?.delete()
checkDownloadItemFinished(downloadItem)
currentDownloadItemParts.remove(downloadItemPart)
}
override fun onCompleted(result: Any) {
Log.d(tag, "DOWNLOAD: FILE MOVE COMPLETED")
val resultDocFile = result as DocumentFile
Log.d(
tag,
"DOWNLOAD: COMPLETED FILE INFO (name=${resultDocFile.name}) ${resultDocFile.getAbsolutePath(mainActivity)}"
)
// Rename to fix appended .mp3 on m4b/m4a files
// REF: https://github.com/anggrayudi/SimpleStorage/issues/94
val docNameLowerCase = resultDocFile.name?.lowercase(Locale.getDefault()) ?: ""
if (docNameLowerCase.endsWith(".m4b.mp3") || docNameLowerCase.endsWith(".m4a.mp3")
) {
resultDocFile.renameTo(downloadItemPart.filename)
} }
downloadItemPart.moved = true
downloadItemPart.isMoving = false
checkDownloadItemFinished(downloadItem)
currentDownloadItemParts.remove(downloadItemPart)
} }
} }
activeCalls[part.id] = InternalDownloadManager(stagingFile, part.fileSize, callback).download(part.serverUrl)
}
val localFolderFile = @Synchronized
DocumentFileCompat.fromUri(mainActivity, Uri.parse(downloadItemPart.localFolderUrl)) private fun startWatchingDownloads() {
if (localFolderFile == null) { if (watcherRunning) return
// Failed watcherRunning = true
downloadItemPart.failed = true scope.launch {
Log.e(tag, "Local Folder File from uri is null") while (true) {
checkDownloadItemFinished(downloadItem) val activeParts = synchronized(this@DownloadItemManager) { currentDownloadItemParts.toList() }
currentDownloadItemParts.remove(downloadItemPart) if (activeParts.isEmpty()) break
} else { activeParts.forEach(::handlePartUpdate)
downloadItemPart.isMoving = true delay(WATCH_INTERVAL_MS)
val mimetype = if (downloadItemPart.audioTrack != null) MimeType.AUDIO else MimeType.IMAGE synchronized(this@DownloadItemManager) { checkUpdateDownloadQueue() }
val fileDescription = }
FileDescription( synchronized(this@DownloadItemManager) { watcherRunning = false }
downloadItemPart.filename,
downloadItemPart.finalDestinationSubfolder,
mimetype
)
file?.moveFileTo(mainActivity, localFolderFile, fileDescription, fcb)
} }
} }
/** Checks if a download item is finished and processes it. */ private fun handlePartUpdate(part: DownloadItemPart) {
clientEventEmitter.onDownloadItemPartUpdate(part)
if (!part.completed) {
val lastUpdate = part.lastUpdateTime ?: return
if (System.currentTimeMillis() - lastUpdate > STALL_TIMEOUT_MS) {
Log.e(tag, "Download stalled: ${part.filename}")
activeCalls.remove(part.id)?.cancel()
synchronized(this) {
part.failed = true
part.completed = true
}
}
return
}
val item = synchronized(this) { downloadItemQueue.find { it.id == part.downloadItemId } }
if (item == null) {
removeActivePart(part)
return
}
if (part.failed) {
removeActivePart(part)
return
}
if (part.isInternalStorage) finalizeInternalFile(item, part) else moveDownloadedFile(item, part)
}
private fun finalizeInternalFile(item: DownloadItem, part: DownloadItemPart) {
if (part.moved || part.isMoving) return
part.isMoving = true
val stagingFile = File(part.destinationPath)
val finalFile = File(part.finalDestinationPath)
finalFile.parentFile?.mkdirs()
if (finalFile.exists() && !finalFile.delete()) {
failFinalization(item, part, "Could not replace existing internal file")
return
}
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) {
if (part.moved || part.isMoving) return
val destinationRoot = DocumentFile.fromTreeUri(mainActivity, Uri.parse(part.localFolderUrl))
if (destinationRoot == null) {
failFinalization(item, part, "Could not resolve SAF destination")
return
}
part.isMoving = true
scope.launch {
try {
val destinationFolder = getOrCreateFolder(destinationRoot, part.finalDestinationSubfolder)
?: throw IllegalStateException("Could not create SAF destination folder")
destinationFolder.findFile(part.filename)?.let { existing ->
if (!existing.delete()) throw IllegalStateException("Could not replace ${part.filename}")
}
val destinationFile = destinationFolder.createFile(mimeTypeFor(part), part.filename)
?: throw IllegalStateException("Could not create ${part.filename}")
val stagingFile = File(part.destinationPath)
FileInputStream(stagingFile).use { input ->
mainActivity.contentResolver.openOutputStream(destinationFile.uri, "w")?.use { output ->
input.copyTo(output)
} ?: throw IllegalStateException("Could not open SAF output stream")
}
if (destinationFile.length() != stagingFile.length()) {
destinationFile.delete()
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) {
failFinalization(item, part, "SAF copy failed: ${e.message}")
}
}
}
private fun getOrCreateFolder(root: DocumentFile, relativePath: String): DocumentFile? {
var current = root
relativePath.split('/').filter { it.isNotBlank() }.forEach { segment ->
if (segment == "." || segment == "..") return null
current = current.findFile(segment) ?: current.createDirectory(segment) ?: return null
}
return current
}
private fun mimeTypeFor(part: DownloadItemPart): String {
return part.audioTrack?.mimeType
?: when (part.ebookFile?.ebookFormat?.lowercase()) {
"epub" -> "application/epub+zip"
"pdf" -> "application/pdf"
else -> "image/jpeg"
}
}
private fun failFinalization(item: DownloadItem, part: DownloadItemPart, message: String) {
Log.e(tag, message)
part.failed = true
part.isMoving = false
part.completed = true
removeActivePart(part)
}
@Synchronized
private fun removeActivePart(part: DownloadItemPart) {
activeCalls.remove(part.id)
currentDownloadItemParts.remove(part)
}
private fun checkDownloadItemFinished(downloadItem: DownloadItem) { private fun checkDownloadItemFinished(downloadItem: DownloadItem) {
if (downloadItem.isDownloadFinished) { if (!downloadItem.isDownloadFinished) return
Log.i(tag, "Download Item finished ${downloadItem.media.metadata.title}") scope.launch {
folderScanner.scanDownloadItem(downloadItem) { scanResult ->
GlobalScope.launch(Dispatchers.IO) { val event =
folderScanner.scanDownloadItem(downloadItem) { downloadItemScanResult -> JSObject().apply {
Log.d( put("libraryItemId", downloadItem.id)
tag, put("localFolderId", downloadItem.localFolder.id)
"Item download complete ${downloadItem.itemTitle} | local library item id: ${downloadItemScanResult?.localLibraryItem?.id}" scanResult?.localLibraryItem?.let {
) put("localLibraryItem", JSObject(jacksonMapper.writeValueAsString(it)))
val jsobj =
JSObject().apply {
put("libraryItemId", downloadItem.id)
put("localFolderId", downloadItem.localFolder.id)
downloadItemScanResult?.localLibraryItem?.let { localLibraryItem ->
put(
"localLibraryItem",
JSObject(jacksonMapper.writeValueAsString(localLibraryItem))
)
}
downloadItemScanResult?.localMediaProgress?.let { localMediaProgress ->
put(
"localMediaProgress",
JSObject(jacksonMapper.writeValueAsString(localMediaProgress))
)
}
} }
scanResult?.localMediaProgress?.let {
launch(Dispatchers.Main) { put("localMediaProgress", JSObject(jacksonMapper.writeValueAsString(it)))
clientEventEmitter.onDownloadItemComplete(jsobj) }
downloadItemQueue.remove(downloadItem) }
DeviceManager.dbManager.removeDownloadItem(downloadItem.id) 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 {
const val APP_MANAGED_DOWNLOAD_ID = -1L
const val WATCH_INTERVAL_MS = 500L
const val STALL_TIMEOUT_MS = 60_000L
}
} }
@@ -1,114 +1,113 @@
package com.audiobookshelf.app.managers package com.audiobookshelf.app.managers
import android.util.Log import android.util.Log
import java.io.* import java.io.File
import java.io.FileOutputStream
import java.io.IOException
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
import okhttp3.* import okhttp3.Call
import okhttp3.Callback
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.Response
/** /** Streams a download into an app-owned staging file. */
* Manages the internal download process.
*
* @property outputStream The output stream to write the downloaded data.
* @property progressCallback The callback to report download progress.
*/
class InternalDownloadManager( class InternalDownloadManager(
private val outputStream: FileOutputStream, private val destinationFile: File,
private val expectedSize: Long,
private val progressCallback: DownloadItemManager.InternalProgressCallback private val progressCallback: DownloadItemManager.InternalProgressCallback
) : AutoCloseable { ) {
private val tag = "InternalDownloadManager" private val tag = "InternalDownloadManager"
private val client: OkHttpClient = private val client =
OkHttpClient.Builder().connectTimeout(30, TimeUnit.SECONDS).build() OkHttpClient.Builder()
private val writer = BinaryFileWriter(outputStream, progressCallback) .connectTimeout(30, TimeUnit.SECONDS)
.readTimeout(60, TimeUnit.SECONDS)
.writeTimeout(60, TimeUnit.SECONDS)
.build()
/** /**
* Downloads a file from the given URL. * 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.
* @param url The URL to download the file from.
* @throws IOException If an I/O error occurs.
*/ */
@Throws(IOException::class) fun download(url: String): Call {
fun download(url: String) { destinationFile.parentFile?.mkdirs()
val request: Request = Request.Builder().url(url).addHeader("Accept-Encoding", "identity").build() val existingBytes = destinationFile.takeIf { it.exists() }?.length() ?: 0L
client.newCall(request) val request =
.enqueue( Request.Builder()
object : Callback { .url(url)
override fun onFailure(call: Call, e: IOException) { .addHeader("Accept-Encoding", "identity")
Log.e(tag, "Download URL $url FAILED", e) .apply {
progressCallback.onComplete(true) if (existingBytes > 0L) header("Range", "bytes=$existingBytes-")
} }
.build()
val call = client.newCall(request)
call.enqueue(
object : Callback {
override fun onFailure(call: Call, e: IOException) {
Log.e(tag, "Download URL failed", e)
progressCallback.onComplete(true)
}
override fun onResponse(call: Call, response: Response) { override fun onResponse(call: Call, response: Response) {
response.body?.let { responseBody -> response.use {
val length: Long = response.header("Content-Length")?.toLongOrNull() ?: 0L try {
writer.write(responseBody.byteStream(), length) val append = existingBytes > 0L && response.code == 206 && hasExpectedRange(response, existingBytes)
if (existingBytes > 0L && !append && response.code != 200) {
Log.e(tag, "Invalid resume response ${response.code} for offset $existingBytes")
progressCallback.onComplete(true)
return
}
if (!response.isSuccessful || response.body == null) {
Log.e(tag, "Download HTTP failure ${response.code}")
progressCallback.onComplete(true)
return
}
val startingBytes = if (append) existingBytes else 0L
val responseLength = response.body!!.contentLength()
val totalLength =
if (expectedSize > 0L) expectedSize
else if (responseLength >= 0L) startingBytes + responseLength
else 0L
FileOutputStream(destinationFile, append).use { output ->
response.body!!.byteStream().use { input ->
val buffer = ByteArray(CHUNK_SIZE)
var totalBytes = startingBytes
while (true) {
val read = input.read(buffer)
if (read < 0) break
output.write(buffer, 0, read)
totalBytes += read
val progress = if (totalLength > 0L) (totalBytes * 100L) / totalLength else 0L
progressCallback.onProgress(totalBytes, progress.coerceAtMost(100L))
} }
?: run {
Log.e(tag, "Response doesn't contain a file")
progressCallback.onComplete(true)
}
} }
} }
)
if (expectedSize > 0L && destinationFile.length() != expectedSize) {
Log.e(tag, "Downloaded size ${destinationFile.length()} did not match $expectedSize")
progressCallback.onComplete(true)
} else {
progressCallback.onComplete(false)
}
} catch (e: IOException) {
Log.e(tag, "Could not write staging file", e)
progressCallback.onComplete(true)
}
}
}
}
)
return call
} }
/** private fun hasExpectedRange(response: Response, offset: Long): Boolean {
* Closes the download manager and releases resources. val range = response.header("Content-Range") ?: return false
* return range.startsWith("bytes $offset-")
* @throws Exception If an error occurs during closing. }
*/
@Throws(Exception::class) private companion object {
override fun close() { const val CHUNK_SIZE = 8 * 1024
writer.close()
}
}
/**
* Writes binary data to an output stream.
*
* @property outputStream The output stream to write the data to.
* @property progressCallback The callback to report write progress.
*/
class BinaryFileWriter(
private val outputStream: OutputStream,
private val progressCallback: DownloadItemManager.InternalProgressCallback
) : AutoCloseable {
/**
* Writes data from the input stream to the output stream.
*
* @param inputStream The input stream to read the data from.
* @param length The total length of the data to be written.
* @return The total number of bytes written.
* @throws IOException If an I/O error occurs.
*/
@Throws(IOException::class)
fun write(inputStream: InputStream, length: Long): Long {
BufferedInputStream(inputStream).use { input ->
val dataBuffer = ByteArray(CHUNK_SIZE)
var totalBytes: Long = 0
var readBytes: Int
while (input.read(dataBuffer).also { readBytes = it } != -1) {
totalBytes += readBytes
outputStream.write(dataBuffer, 0, readBytes)
progressCallback.onProgress(totalBytes, (totalBytes * 100L) / length)
}
progressCallback.onComplete(false)
return totalBytes
}
}
/**
* Closes the writer and releases resources.
*
* @throws IOException If an error occurs during closing.
*/
@Throws(IOException::class)
override fun close() {
outputStream.close()
}
companion object {
private const val CHUNK_SIZE = 8192 // Increased chunk size for better performance
} }
} }
@@ -25,7 +25,7 @@ data class DownloadItem(
val isInternalStorage get() = localFolder.id.startsWith("internal-") val isInternalStorage get() = localFolder.id.startsWith("internal-")
@get:JsonIgnore @get:JsonIgnore
val isDownloadFinished get() = !downloadItemParts.any { !it.completed || it.isMoving } val isDownloadFinished get() = !downloadItemParts.any { !it.completed || it.isMoving || it.failed }
@JsonIgnore @JsonIgnore
fun getNextDownloadItemParts(limit:Int): MutableList<DownloadItemPart> { fun getNextDownloadItemParts(limit:Int): MutableList<DownloadItemPart> {
@@ -1,6 +1,5 @@
package com.audiobookshelf.app.models package com.audiobookshelf.app.models
import android.app.DownloadManager
import android.net.Uri import android.net.Uri
import android.util.Log import android.util.Log
import com.audiobookshelf.app.data.AudioTrack import com.audiobookshelf.app.data.AudioTrack
@@ -16,6 +15,8 @@ data class DownloadItemPart(
val downloadItemId: String, val downloadItemId: String,
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. */
val destinationPath: String,
val finalDestinationPath:String, val finalDestinationPath:String,
val serverPath: String, val serverPath: String,
val localFolderName: String, val localFolderName: String,
@@ -31,8 +32,11 @@ data class DownloadItemPart(
@JsonIgnore val uri: Uri, @JsonIgnore val uri: Uri,
@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. */
@JsonIgnore var completedDestinationUri: String?,
val finalDestinationSubfolder: String, val finalDestinationSubfolder: String,
var downloadId: Long?, var downloadId: Long?,
var lastUpdateTime: Long?,
var progress: Long, var progress: Long,
var bytesDownloaded: Long var bytesDownloaded: Long
) { ) {
@@ -53,6 +57,7 @@ data class DownloadItemPart(
downloadItemId, downloadItemId,
filename = filename, filename = filename,
fileSize = fileSize, fileSize = fileSize,
destinationPath = destinationFile.absolutePath,
finalDestinationPath = finalDestinationFile.absolutePath, finalDestinationPath = finalDestinationFile.absolutePath,
serverPath = serverPath, serverPath = serverPath,
localFolderName = localFolder.name, localFolderName = localFolder.name,
@@ -68,8 +73,10 @@ data class DownloadItemPart(
uri = downloadUri, uri = downloadUri,
destinationUri = destinationUri, destinationUri = destinationUri,
finalDestinationUri = finalDestinationUri, finalDestinationUri = finalDestinationUri,
completedDestinationUri = null,
finalDestinationSubfolder = subfolder, finalDestinationSubfolder = subfolder,
downloadId = null, downloadId = null,
lastUpdateTime = null,
progress = 0, progress = 0,
bytesDownloaded = 0 bytesDownloaded = 0
) )
@@ -82,13 +89,4 @@ data class DownloadItemPart(
@get:JsonIgnore @get:JsonIgnore
val serverUrl get() = uri.toString() val serverUrl get() = uri.toString()
@JsonIgnore
fun getDownloadRequest(): DownloadManager.Request {
val dlRequest = DownloadManager.Request(uri)
dlRequest.setTitle(filename)
dlRequest.setDescription("Downloading to $localFolderName with filename $filename")
dlRequest.setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE)
dlRequest.setDestinationUri(destinationUri)
return dlRequest
}
} }
@@ -229,7 +229,7 @@ class AbsAudioPlayer : Plugin() {
return call.resolve(JSObject("{\"error\":\"Podcast episode not found\"}")) return call.resolve(JSObject("{\"error\":\"Podcast episode not found\"}"))
} }
} }
if (!it.hasTracks(episode)) { if (!it.hasTracks(mainActivity, episode)) {
return call.resolve(JSObject("{\"error\":\"No audio files found on device. Download book again to fix.\"}")) return call.resolve(JSObject("{\"error\":\"No audio files found on device. Download book again to fix.\"}"))
} }
@@ -40,7 +40,7 @@ class AbsDatabase : Plugin() {
secureStorage = SecureStorage(mainActivity) secureStorage = SecureStorage(mainActivity)
DeviceManager.dbManager.cleanLocalMediaProgress() DeviceManager.dbManager.cleanLocalMediaProgress()
DeviceManager.dbManager.cleanLocalLibraryItems() DeviceManager.dbManager.cleanLocalLibraryItems(mainActivity)
DeviceManager.dbManager.cleanLogs() DeviceManager.dbManager.cleanLogs()
} }
@@ -1,7 +1,5 @@
package com.audiobookshelf.app.plugins package com.audiobookshelf.app.plugins
import android.app.DownloadManager
import android.content.Context
import android.os.Environment import android.os.Environment
import android.util.Log import android.util.Log
import com.audiobookshelf.app.MainActivity import com.audiobookshelf.app.MainActivity
@@ -27,7 +25,6 @@ class AbsDownloader : Plugin() {
private var jacksonMapper = jacksonObjectMapper().enable(JsonReadFeature.ALLOW_UNESCAPED_CONTROL_CHARS.mappedFeature()) private var jacksonMapper = jacksonObjectMapper().enable(JsonReadFeature.ALLOW_UNESCAPED_CONTROL_CHARS.mappedFeature())
lateinit var mainActivity: MainActivity lateinit var mainActivity: MainActivity
lateinit var downloadManager: DownloadManager
lateinit var apiHandler: ApiHandler lateinit var apiHandler: ApiHandler
lateinit var folderScanner: FolderScanner lateinit var folderScanner: FolderScanner
lateinit var downloadItemManager: DownloadItemManager lateinit var downloadItemManager: DownloadItemManager
@@ -46,10 +43,14 @@ class AbsDownloader : Plugin() {
override fun load() { override fun load() {
mainActivity = (activity as MainActivity) mainActivity = (activity as MainActivity)
downloadManager = activity.getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager
folderScanner = FolderScanner(mainActivity) folderScanner = FolderScanner(mainActivity)
apiHandler = ApiHandler(mainActivity) apiHandler = ApiHandler(mainActivity)
downloadItemManager = DownloadItemManager(downloadManager, folderScanner, mainActivity, clientEventEmitter) downloadItemManager = DownloadItemManager(folderScanner, mainActivity, clientEventEmitter)
}
override fun handleOnDestroy() {
if (::downloadItemManager.isInitialized) downloadItemManager.destroy()
super.handleOnDestroy()
} }
@PluginMethod @PluginMethod
@@ -132,7 +133,15 @@ class AbsDownloader : Plugin() {
private fun startLibraryItemDownload(libraryItem: LibraryItem, localFolder: LocalFolder, episode:PodcastEpisode?) { private fun startLibraryItemDownload(libraryItem: LibraryItem, localFolder: LocalFolder, episode:PodcastEpisode?) {
val isInternal = localFolder.id.startsWith("internal-") val isInternal = localFolder.id.startsWith("internal-")
val tempFolderPath = if (isInternal) "${mainActivity.filesDir}/downloads/${libraryItem.id}" else mainActivity.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS) val finalInternalFolderPath = "${mainActivity.filesDir}/downloads/${libraryItem.id}"
// Keep internal staging on the same filesystem as its final file so finalization is a rename.
// External-folder downloads can use app external storage and are moved through SAF afterwards.
val tempFolderPath =
if (isInternal) {
"${mainActivity.filesDir}/download-staging/${libraryItem.id}"
} else {
"${mainActivity.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS) ?: mainActivity.filesDir}/download-staging/${libraryItem.id}"
}
Log.d(tag, "downloadCacheDirectory=$tempFolderPath") Log.d(tag, "downloadCacheDirectory=$tempFolderPath")
@@ -143,7 +152,7 @@ class AbsDownloader : Plugin() {
val tracks = libraryItem.media.getAudioTracks() val tracks = libraryItem.media.getAudioTracks()
Log.d(tag, "Starting library item download with ${tracks.size} tracks") Log.d(tag, "Starting library item download with ${tracks.size} tracks")
val itemSubfolder = "$bookAuthor/$bookTitle" val itemSubfolder = "$bookAuthor/$bookTitle"
val itemFolderPath = if (isInternal) "$tempFolderPath" 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())
val book = libraryItem.media as Book val book = libraryItem.media as Book
@@ -152,12 +161,7 @@ class AbsDownloader : Plugin() {
val serverPath = "/api/items/${libraryItem.id}/file/${ebookFile.ino}/download" val serverPath = "/api/items/${libraryItem.id}/file/${ebookFile.ino}/download"
val destinationFilename = getFilenameFromRelPath(ebookFile.metadata?.relPath ?: "") val destinationFilename = getFilenameFromRelPath(ebookFile.metadata?.relPath ?: "")
val finalDestinationFile = File("$itemFolderPath/$destinationFilename") val finalDestinationFile = File("$itemFolderPath/$destinationFilename")
val destinationFile = File("$tempFolderPath/$destinationFilename") val destinationFile = File("$tempFolderPath/$destinationFilename.part")
if (destinationFile.exists()) {
Log.d(tag, "TEMP ebook file already exists, removing it from ${destinationFile.absolutePath}")
destinationFile.delete()
}
if (finalDestinationFile.exists()) { if (finalDestinationFile.exists()) {
Log.d(tag, "ebook file already exists, removing it from ${finalDestinationFile.absolutePath}") Log.d(tag, "ebook file already exists, removing it from ${finalDestinationFile.absolutePath}")
@@ -181,12 +185,7 @@ class AbsDownloader : Plugin() {
Log.d(tag, "Audio File Server Path $serverPath | AF RelPath ${audioTrack.relPath} | LocalFolder Path ${localFolder.absolutePath} | DestName $destinationFilename") 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") val destinationFile = File("$tempFolderPath/$destinationFilename.part")
if (destinationFile.exists()) {
Log.d(tag, "TEMP Audio file already exists, removing it from ${destinationFile.absolutePath}")
destinationFile.delete()
}
if (finalDestinationFile.exists()) { if (finalDestinationFile.exists()) {
Log.d(tag, "Audio file already exists, removing it from ${finalDestinationFile.absolutePath}") Log.d(tag, "Audio file already exists, removing it from ${finalDestinationFile.absolutePath}")
@@ -205,14 +204,9 @@ class AbsDownloader : Plugin() {
val serverPath = "/api/items/${libraryItem.id}/cover" val serverPath = "/api/items/${libraryItem.id}/cover"
val destinationFilename = "cover-${libraryItem.id}.jpg" val destinationFilename = "cover-${libraryItem.id}.jpg"
val destinationFile = File("$tempFolderPath/$destinationFilename") val destinationFile = File("$tempFolderPath/$destinationFilename.part")
val finalDestinationFile = File("$itemFolderPath/$destinationFilename") val finalDestinationFile = File("$itemFolderPath/$destinationFilename")
if (destinationFile.exists()) {
Log.d(tag, "TEMP Audio file already exists, removing it from ${destinationFile.absolutePath}")
destinationFile.delete()
}
if (finalDestinationFile.exists()) { if (finalDestinationFile.exists()) {
Log.d(tag, "Cover already exists, removing it from ${finalDestinationFile.absolutePath}") Log.d(tag, "Cover already exists, removing it from ${finalDestinationFile.absolutePath}")
finalDestinationFile.delete() finalDestinationFile.delete()
@@ -233,7 +227,7 @@ class AbsDownloader : Plugin() {
val fileSize = audioTrack?.metadata?.size ?: 0 val fileSize = audioTrack?.metadata?.size ?: 0
Log.d(tag, "Starting podcast episode download") Log.d(tag, "Starting podcast episode download")
val itemFolderPath = if (isInternal) "$tempFolderPath" 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())
@@ -241,7 +235,7 @@ class AbsDownloader : Plugin() {
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") Log.d(tag, "Audio File Server Path $serverPath | AF RelPath ${audioTrack?.relPath} | LocalFolder Path ${localFolder.absolutePath} | DestName $destinationFilename")
var destinationFile = File("$tempFolderPath/$destinationFilename") var destinationFile = File("$tempFolderPath/$destinationFilename.part")
var finalDestinationFile = File("$itemFolderPath/$destinationFilename") var finalDestinationFile = File("$itemFolderPath/$destinationFilename")
if (finalDestinationFile.exists()) { if (finalDestinationFile.exists()) {
Log.d(tag, "Audio file already exists, removing it from ${finalDestinationFile.absolutePath}") Log.d(tag, "Audio file already exists, removing it from ${finalDestinationFile.absolutePath}")
@@ -258,7 +252,7 @@ class AbsDownloader : Plugin() {
serverPath = "/api/items/${libraryItem.id}/cover" serverPath = "/api/items/${libraryItem.id}/cover"
destinationFilename = "cover.jpg" destinationFilename = "cover.jpg"
destinationFile = File("$tempFolderPath/$destinationFilename") destinationFile = File("$tempFolderPath/$destinationFilename.part")
finalDestinationFile = File("$itemFolderPath/$destinationFilename") finalDestinationFile = File("$itemFolderPath/$destinationFilename")
if (finalDestinationFile.exists()) { if (finalDestinationFile.exists()) {