diff --git a/android/app/src/main/java/com/audiobookshelf/app/device/FolderScanner.kt b/android/app/src/main/java/com/audiobookshelf/app/device/FolderScanner.kt index 4cefaa04..cfdd169d 100644 --- a/android/app/src/main/java/com/audiobookshelf/app/device/FolderScanner.kt +++ b/android/app/src/main/java/com/audiobookshelf/app/device/FolderScanner.kt @@ -222,14 +222,16 @@ class FolderScanner(var ctx: Context) { // Scan item after download and create local library item fun scanDownloadItem(downloadItem: DownloadItem):DownloadItemScanResult? { val folderDf = DocumentFileCompat.fromUri(ctx, Uri.parse(downloadItem.localFolder.contentUrl)) - val foldersFound = folderDf?.search(false, DocumentFileType.FOLDER) ?: mutableListOf() + val foldersFound = folderDf?.search(true, DocumentFileType.FOLDER) ?: mutableListOf() var itemFolderId = "" var itemFolderUrl = "" var itemFolderBasePath = "" var itemFolderAbsolutePath = "" foldersFound.forEach { - if (it.name == downloadItem.itemTitle) { + // 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) diff --git a/android/app/src/main/java/com/audiobookshelf/app/managers/DownloadItemManager.kt b/android/app/src/main/java/com/audiobookshelf/app/managers/DownloadItemManager.kt index f51cbece..547f5e74 100644 --- a/android/app/src/main/java/com/audiobookshelf/app/managers/DownloadItemManager.kt +++ b/android/app/src/main/java/com/audiobookshelf/app/managers/DownloadItemManager.kt @@ -34,8 +34,8 @@ class DownloadItemManager(var downloadManager:DownloadManager, var folderScanner Failed } - var downloadItemQueue: MutableList = mutableListOf() - var currentDownloadItemParts: MutableList = mutableListOf() + var downloadItemQueue: MutableList = mutableListOf() // All pending and downloading items + var currentDownloadItemParts: MutableList = mutableListOf() // Item parts currently being downloaded interface DownloadEventEmitter { fun onDownloadItem(downloadItem:DownloadItem) @@ -175,11 +175,13 @@ class DownloadItemManager(var downloadManager:DownloadManager, var folderScanner override fun onCompleted(result:Any) { Log.d(tag, "DOWNLOAD: FILE MOVE COMPLETED") val resultDocFile = result as DocumentFile - Log.d(tag, "DOWNLOAD: COMPLETED FILE INFO ${resultDocFile.getAbsolutePath(mainActivity)}") + Log.d(tag, "DOWNLOAD: COMPLETED FILE INFO (name=${resultDocFile.name}) ${resultDocFile.getAbsolutePath(mainActivity)}") - // Rename to fix appended .mp4 on m4b files + // Rename to fix appended .mp3 on m4b/m4a files // REF: https://github.com/anggrayudi/SimpleStorage/issues/94 - resultDocFile.renameTo(downloadItemPart.filename) + if (resultDocFile.name?.endsWith(".m4b.mp3") == true || resultDocFile.name?.endsWith(".m4a.mp3") == true) { + resultDocFile.renameTo(downloadItemPart.filename) + } downloadItemPart.moved = true downloadItemPart.isMoving = false @@ -198,7 +200,7 @@ class DownloadItemManager(var downloadManager:DownloadManager, var folderScanner } else { downloadItemPart.isMoving = true val mimetype = if (downloadItemPart.audioTrack != null) MimeType.AUDIO else MimeType.IMAGE - val fileDescription = FileDescription(downloadItemPart.filename, downloadItemPart.itemTitle, mimetype) + val fileDescription = FileDescription(downloadItemPart.filename, downloadItemPart.finalDestinationSubfolder, mimetype) file?.moveFileTo(mainActivity, localFolderFile, fileDescription, fcb) } diff --git a/android/app/src/main/java/com/audiobookshelf/app/models/DownloadItem.kt b/android/app/src/main/java/com/audiobookshelf/app/models/DownloadItem.kt index dd1812dd..fe9a7540 100644 --- a/android/app/src/main/java/com/audiobookshelf/app/models/DownloadItem.kt +++ b/android/app/src/main/java/com/audiobookshelf/app/models/DownloadItem.kt @@ -17,6 +17,7 @@ data class DownloadItem( val itemFolderPath:String, val localFolder: LocalFolder, val itemTitle: String, + val itemSubfolder: String, val media: MediaType, val downloadItemParts: MutableList ) { diff --git a/android/app/src/main/java/com/audiobookshelf/app/models/DownloadItemPart.kt b/android/app/src/main/java/com/audiobookshelf/app/models/DownloadItemPart.kt index f1872e2a..17baf455 100644 --- a/android/app/src/main/java/com/audiobookshelf/app/models/DownloadItemPart.kt +++ b/android/app/src/main/java/com/audiobookshelf/app/models/DownloadItemPart.kt @@ -16,7 +16,6 @@ data class DownloadItemPart( val downloadItemId: String, val filename: String, val finalDestinationPath:String, - val itemTitle: String, val serverPath: String, val localFolderName: String, val localFolderUrl: String, @@ -30,11 +29,12 @@ data class DownloadItemPart( @JsonIgnore val uri: Uri, @JsonIgnore val destinationUri: Uri, @JsonIgnore val finalDestinationUri: Uri, + val finalDestinationSubfolder: String, var downloadId: Long?, var progress: Long ) { companion object { - fun make(downloadItemId:String, filename:String, destinationFile: File, finalDestinationFile: File, itemTitle:String, serverPath:String, localFolder: LocalFolder, audioTrack: AudioTrack?, episode: PodcastEpisode?) :DownloadItemPart { + fun make(downloadItemId:String, filename:String, destinationFile: File, finalDestinationFile: File, subfolder:String, serverPath:String, localFolder: LocalFolder, audioTrack: AudioTrack?, episode: PodcastEpisode?) :DownloadItemPart { val destinationUri = Uri.fromFile(destinationFile) val finalDestinationUri = Uri.fromFile(finalDestinationFile) @@ -47,7 +47,6 @@ data class DownloadItemPart( downloadItemId, filename = filename, finalDestinationPath = finalDestinationFile.absolutePath, - itemTitle = itemTitle, serverPath = serverPath, localFolderName = localFolder.name, localFolderUrl = localFolder.contentUrl, @@ -61,6 +60,7 @@ data class DownloadItemPart( uri = downloadUri, destinationUri = destinationUri, finalDestinationUri = finalDestinationUri, + finalDestinationSubfolder = subfolder, downloadId = null, progress = 0 ) @@ -74,7 +74,7 @@ data class DownloadItemPart( fun getDownloadRequest(): DownloadManager.Request { val dlRequest = DownloadManager.Request(uri) dlRequest.setTitle(filename) - dlRequest.setDescription("Downloading to $localFolderName for book $itemTitle") + dlRequest.setDescription("Downloading to $localFolderName with filename $filename") dlRequest.setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE) dlRequest.setDestinationUri(destinationUri) return dlRequest diff --git a/android/app/src/main/java/com/audiobookshelf/app/plugins/AbsDownloader.kt b/android/app/src/main/java/com/audiobookshelf/app/plugins/AbsDownloader.kt index 9c501b90..1b5bcd30 100644 --- a/android/app/src/main/java/com/audiobookshelf/app/plugins/AbsDownloader.kt +++ b/android/app/src/main/java/com/audiobookshelf/app/plugins/AbsDownloader.kt @@ -2,15 +2,9 @@ package com.audiobookshelf.app.plugins import android.app.DownloadManager import android.content.Context -import android.net.Uri import android.os.Build import android.os.Environment import android.util.Log -import androidx.documentfile.provider.DocumentFile -import com.anggrayudi.storage.callback.FileCallback -import com.anggrayudi.storage.file.* -import com.anggrayudi.storage.media.FileDescription -import com.anggrayudi.storage.media.MediaStoreCompat import com.audiobookshelf.app.MainActivity import com.audiobookshelf.app.data.* import com.audiobookshelf.app.device.DeviceManager @@ -26,10 +20,6 @@ import com.getcapacitor.Plugin import com.getcapacitor.PluginCall import com.getcapacitor.PluginMethod import com.getcapacitor.annotation.CapacitorPlugin -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.GlobalScope -import kotlinx.coroutines.delay -import kotlinx.coroutines.launch import java.io.File @CapacitorPlugin(name = "AbsDownloader") @@ -43,9 +33,7 @@ class AbsDownloader : Plugin() { lateinit var folderScanner: FolderScanner lateinit var downloadItemManager: DownloadItemManager - private var downloadQueue: MutableList = mutableListOf() - - val clientEventEmitter = (object : DownloadItemManager.DownloadEventEmitter { + private val clientEventEmitter = (object : DownloadItemManager.DownloadEventEmitter { override fun onDownloadItem(downloadItem:DownloadItem) { notifyListeners("onDownloadItem", JSObject(jacksonMapper.writeValueAsString(downloadItem))) } @@ -75,7 +63,7 @@ class AbsDownloader : Plugin() { Log.d(tag, "Download library item $libraryItemId to folder $localFolderId / episode: $episodeId") val downloadId = if (episodeId.isEmpty()) libraryItemId else "$libraryItemId-$episodeId" - if (downloadQueue.find { it.id == downloadId } != null) { + if (downloadItemManager.downloadItemQueue.find { it.id == downloadId } != null) { Log.d(tag, "Download already started for this media entity $downloadId") return call.resolve(JSObject("{\"error\":\"Download already started for this media entity\"}")) } @@ -142,17 +130,18 @@ class AbsDownloader : Plugin() { private fun startLibraryItemDownload(libraryItem: LibraryItem, localFolder: LocalFolder, episode:PodcastEpisode?) { val tempFolderPath = mainActivity.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS) -// val tempFolderPath = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS) Log.d(tag, "downloadCacheDirectory=$tempFolderPath") if (libraryItem.mediaType == "book") { val bookTitle = cleanStringForFileSystem(libraryItem.media.metadata.title) + val bookAuthor = cleanStringForFileSystem(libraryItem.media.metadata.getAuthorDisplayName()) val tracks = libraryItem.media.getAudioTracks() Log.d(tag, "Starting library item download with ${tracks.size} tracks") - val itemFolderPath = "${localFolder.absolutePath}/$bookTitle" - val downloadItem = DownloadItem(libraryItem.id, libraryItem.id, null, libraryItem.userMediaProgress,DeviceManager.serverConnectionConfig?.id ?: "", DeviceManager.serverAddress, DeviceManager.serverUserId, libraryItem.mediaType, itemFolderPath, localFolder, bookTitle, libraryItem.media, mutableListOf()) + val itemSubfolder = "$bookAuthor/$bookTitle" + val itemFolderPath = "${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()) // Create download item part for each audio track tracks.forEach { audioTrack -> @@ -173,12 +162,8 @@ class AbsDownloader : Plugin() { finalDestinationFile.delete() } - val downloadItemPart = DownloadItemPart.make(downloadItem.id, destinationFilename,destinationFile,finalDestinationFile,bookTitle,serverPath,localFolder,audioTrack,null) + val downloadItemPart = DownloadItemPart.make(downloadItem.id, destinationFilename,destinationFile,finalDestinationFile,itemSubfolder,serverPath,localFolder,audioTrack,null) downloadItem.downloadItemParts.add(downloadItemPart) - -// val dlRequest = downloadItemPart.getDownloadRequest() -// val downloadId = downloadManager.enqueue(dlRequest) -// downloadItemPart.downloadId = downloadId } if (downloadItem.downloadItemParts.isNotEmpty()) { @@ -199,17 +184,10 @@ class AbsDownloader : Plugin() { finalDestinationFile.delete() } - val downloadItemPart = DownloadItemPart.make(downloadItem.id, destinationFilename,destinationFile,finalDestinationFile,bookTitle,serverPath,localFolder,null,null) + val downloadItemPart = DownloadItemPart.make(downloadItem.id, destinationFilename,destinationFile,finalDestinationFile,itemSubfolder,serverPath,localFolder,null,null) downloadItem.downloadItemParts.add(downloadItemPart) - -// val dlRequest = downloadItemPart.getDownloadRequest() -// val downloadId = downloadManager.enqueue(dlRequest) -// downloadItemPart.downloadId = downloadId } -// downloadQueue.add(downloadItem) -// startWatchingDownloads(downloadItem) -// DeviceManager.dbManager.saveDownloadItem(downloadItem) downloadItemManager.addDownloadItem(downloadItem) } } else { @@ -220,7 +198,7 @@ class AbsDownloader : Plugin() { Log.d(tag, "Starting podcast episode download") val itemFolderPath = localFolder.absolutePath + "/" + podcastTitle 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, 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 = "/s/item/${libraryItem.id}/${cleanRelPath(audioTrack?.relPath ?: "")}" var destinationFilename = getFilenameFromRelPath(audioTrack?.relPath ?: "") @@ -236,10 +214,6 @@ class AbsDownloader : Plugin() { var downloadItemPart = DownloadItemPart.make(downloadItem.id, destinationFilename,destinationFile,finalDestinationFile,podcastTitle,serverPath,localFolder,audioTrack,episode) downloadItem.downloadItemParts.add(downloadItemPart) - var dlRequest = downloadItemPart.getDownloadRequest() - var downloadId = downloadManager.enqueue(dlRequest) - downloadItemPart.downloadId = downloadId - if (libraryItem.media.coverPath != null && libraryItem.media.coverPath?.isNotEmpty() == true) { serverPath = "/api/items/${libraryItem.id}/cover" destinationFilename = "cover.jpg" @@ -252,132 +226,10 @@ class AbsDownloader : Plugin() { } else { downloadItemPart = DownloadItemPart.make(downloadItem.id, destinationFilename,destinationFile,finalDestinationFile,podcastTitle,serverPath,localFolder,null,null) downloadItem.downloadItemParts.add(downloadItemPart) - - dlRequest = downloadItemPart.getDownloadRequest() - downloadId = downloadManager.enqueue(dlRequest) - downloadItemPart.downloadId = downloadId } } - downloadQueue.add(downloadItem) - startWatchingDownloads(downloadItem) - DeviceManager.dbManager.saveDownloadItem(downloadItem) - } - } - - private fun startWatchingDownloads(downloadItem: DownloadItem) { - GlobalScope.launch(Dispatchers.IO) { - while (downloadItem.downloadItemParts.find { !it.moved && !it.failed } != null) { // While some item is not completed - val numPartsBefore = downloadItem.downloadItemParts.size - checkDownloads(downloadItem) - - // Keep database updated as item parts finish downloading - if (downloadItem.downloadItemParts.size > 0 && downloadItem.downloadItemParts.size != numPartsBefore) { - Log.d(tag, "Save download item on num parts changed from $numPartsBefore to ${downloadItem.downloadItemParts.size}") - DeviceManager.dbManager.saveDownloadItem(downloadItem) - } - - notifyListeners("onItemDownloadUpdate", JSObject(jacksonMapper.writeValueAsString(downloadItem))) - delay(500) - } - - // Remove download notifications - downloadItem.downloadItemParts.forEach { downloadItemPart -> - downloadItemPart.downloadId?.let { - downloadManager.remove(it) - } - } - - val downloadItemScanResult = folderScanner.scanDownloadItem(downloadItem) - DeviceManager.dbManager.removeDownloadItem(downloadItem.id) - downloadQueue.remove(downloadItem) - - Log.d(tag, "Item download complete ${downloadItem.itemTitle} | local library item id: ${downloadItemScanResult?.localLibraryItem?.id} | Items remaining in Queue ${downloadQueue.size}") - - val jsobj = JSObject() - jsobj.put("libraryItemId", downloadItem.id) - jsobj.put("localFolderId", downloadItem.localFolder.id) - - downloadItemScanResult?.localLibraryItem?.let { localLibraryItem -> - jsobj.put("localLibraryItem", JSObject(jacksonMapper.writeValueAsString(localLibraryItem))) - } - downloadItemScanResult?.localMediaProgress?.let { localMediaProgress -> - jsobj.put("localMediaProgress", JSObject(jacksonMapper.writeValueAsString(localMediaProgress))) - } - notifyListeners("onItemDownloadComplete", jsobj) - } - } - - private fun checkDownloads(downloadItem: DownloadItem) { - val itemParts = downloadItem.downloadItemParts.map { it } - for (downloadItemPart in itemParts) { - if (downloadItemPart.downloadId != null) { - val dlid = downloadItemPart.downloadId!! - val query = DownloadManager.Query().setFilterById(dlid) - 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.getInt(bytesDownloadedColumnIndex) else 0 - Log.d(tag, "checkDownloads Download ${downloadItemPart.filename} bytes $totalBytes | bytes dled $bytesDownloadedSoFar | downloadStatus $downloadStatus") - - if (downloadStatus == DownloadManager.STATUS_SUCCESSFUL) { - // Once file download is complete move the file to the final destination - if (!downloadItemPart.completed) { - Log.d(tag, "checkDownloads Download ${downloadItemPart.filename} Done") - downloadItemPart.completed = true - - val file = DocumentFileCompat.fromUri(mainActivity, downloadItemPart.destinationUri) - Log.d(tag, "DOWNLOAD: Attempt move for file at destination ${downloadItemPart.destinationUri} | ${file?.getBasePath(mainActivity)}") - - 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 - file?.delete() - } - override fun onCompleted(result:Any) { - Log.d(tag, "DOWNLOAD: FILE MOVE COMPLETED") - val resultDocFile = result as DocumentFile - Log.d(tag, "DOWNLOAD: COMPLETED FILE INFO ${resultDocFile.getAbsolutePath(mainActivity)}") - downloadItemPart.moved = true - } - } - - Log.d(tag, "DOWNLOAD: Move file to final destination path: ${downloadItemPart.finalDestinationPath}") - val localFolderFile = DocumentFileCompat.fromUri(mainActivity,Uri.parse(downloadItemPart.localFolderUrl)) - if (localFolderFile == null) { - Log.e(tag, "Local Folder File from uri is null") - } else { - val mimetype = if (downloadItemPart.audioTrack != null) MimeType.AUDIO else MimeType.IMAGE - val fileDescription = FileDescription(downloadItemPart.filename, downloadItemPart.itemTitle, mimetype) - file?.moveFileTo(mainActivity,localFolderFile,fileDescription,fcb) - } - } else { - // Why is kotlin requiring an else here.. - } - } else if (downloadStatus == DownloadManager.STATUS_FAILED) { - Log.d(tag, "checkDownloads Download ${downloadItemPart.filename} Failed") - downloadItem.downloadItemParts.remove(downloadItemPart) - } else { - //update progress - val percentProgress = if (totalBytes > 0) ((bytesDownloadedSoFar * 100L) / totalBytes) else 0 - Log.d(tag, "checkDownloads Download ${downloadItemPart.filename} Progress = $percentProgress%") - downloadItemPart.progress = percentProgress - } - } else { - Log.d(tag, "Download ${downloadItemPart.filename} not found in dlmanager") - downloadItem.downloadItemParts.remove(downloadItemPart) - } - } - } + downloadItemManager.addDownloadItem(downloadItem) } } } diff --git a/pages/media/_id/history.vue b/pages/media/_id/history.vue index 929a5fec..55b3040d 100644 --- a/pages/media/_id/history.vue +++ b/pages/media/_id/history.vue @@ -32,10 +32,6 @@ export default { async asyncData({ params, store, redirect, app, query }) { const mediaItemHistory = await app.$db.getMediaItemHistory(params.id) - if (!mediaItemHistory) { - return redirect('/?error=Media Item Not Found') - } - return { title: query.title || 'Unknown', mediaItemHistory