diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index f9c84bb3..424b4a6a 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -7,6 +7,7 @@ + diff --git a/android/app/src/main/java/com/audiobookshelf/app/AudioDownloader.kt b/android/app/src/main/java/com/audiobookshelf/app/AudioDownloader.kt index 83107ae0..e6cb33ef 100644 --- a/android/app/src/main/java/com/audiobookshelf/app/AudioDownloader.kt +++ b/android/app/src/main/java/com/audiobookshelf/app/AudioDownloader.kt @@ -7,38 +7,77 @@ import android.os.Build import android.os.Environment import android.util.Log import androidx.documentfile.provider.DocumentFile +import com.anggrayudi.storage.SimpleStorage import com.anggrayudi.storage.callback.FileCallback import com.anggrayudi.storage.file.* import com.anggrayudi.storage.media.FileDescription +import com.audiobookshelf.app.data.LibraryItem +import com.audiobookshelf.app.data.LocalFolder +import com.audiobookshelf.app.device.DeviceManager +import com.audiobookshelf.app.device.FolderScanner +import com.audiobookshelf.app.server.ApiHandler +import com.fasterxml.jackson.annotation.JsonIgnore +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper import com.getcapacitor.JSObject 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 +import java.io.FileOutputStream +import java.util.* @CapacitorPlugin(name = "AudioDownloader") class AudioDownloader : Plugin() { private val tag = "AudioDownloader" - lateinit var mainActivity:MainActivity - lateinit var downloadManager:DownloadManager + lateinit var mainActivity: MainActivity + lateinit var downloadManager: DownloadManager + lateinit var apiHandler: ApiHandler + lateinit var folderScanner: FolderScanner -// data class AudiobookItem(val uri: Uri, val name: String, val size: Long, val coverUrl: String) { -// fun toJSObject() : JSObject { -// var obj = JSObject() -// obj.put("uri", this.uri) -// obj.put("name", this.name) -// obj.put("size", this.size) -// obj.put("coverUrl", this.coverUrl) -// return obj -// } -// } + data class DownloadItemPart( + val id: String, + val name: String, + val itemTitle: String, + val serverPath: String, + val folderName: String, + val localFolderId: String, + @JsonIgnore val uri: Uri, + @JsonIgnore val destinationUri: Uri, + var downloadId: Long?, + var progress: Long + ) { + @JsonIgnore + fun getDownloadRequest(): DownloadManager.Request { + var dlRequest = DownloadManager.Request(uri) + dlRequest.setTitle(name) + dlRequest.setDescription("Downloading to $folderName for book $itemTitle") + dlRequest.setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED) + dlRequest.setDestinationUri(destinationUri) + return dlRequest + } + } + + data class DownloadItem( + val id: String, + val localFolder: LocalFolder, + val itemTitle: String, + val downloadItemParts: MutableList + ) + + var downloadQueue: MutableList = mutableListOf() override fun load() { mainActivity = (activity as MainActivity) downloadManager = activity.getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager + folderScanner = FolderScanner(mainActivity) + apiHandler = ApiHandler(mainActivity) var recieverEvent: (evt: String, id: Long) -> Unit = { evt: String, id: Long -> if (evt == "complete") { @@ -52,303 +91,355 @@ class AudioDownloader : Plugin() { Log.d(tag, "Build SDK ${Build.VERSION.SDK_INT}") } - -// @PluginMethod -// fun load(call: PluginCall) { -// var audiobookUrls = call.data.getJSONArray("audiobookUrls") -// var len = audiobookUrls?.length() -// if (len == null) { -// len = 0 -// } -// Log.d(tag, "CALLED LOAD $len") -// var audiobookItems:MutableList = mutableListOf() -// -// (0 until len).forEach { -// var jsobj = audiobookUrls.get(it) as JSONObject -// var audiobookUrl = jsobj.get("contentUrl").toString() -// var coverUrl = jsobj.get("coverUrl").toString() -// var storageId = "" -// if(jsobj.has("storageId")) jsobj.get("storageId").toString() -// -// var basePath = "" -// if(jsobj.has("basePath")) jsobj.get("basePath").toString() -// -// var coverBasePath = "" -// if(jsobj.has("coverBasePath")) jsobj.get("coverBasePath").toString() -// -// Log.d(tag, "LOOKUP $storageId $basePath $audiobookUrl") -// -// var audiobookFile: DocumentFile? = null -// var coverFile: DocumentFile? = null -// -// // Android 9 OR Below use storage id and base path -// if (Build.VERSION.SDK_INT <= android.os.Build.VERSION_CODES.P) { -// audiobookFile = DocumentFileCompat.fromSimplePath(context, storageId, basePath) -// if (coverUrl != null && coverUrl != "") { -// coverFile = DocumentFileCompat.fromSimplePath(context, storageId, coverBasePath) -// } -// } else { -// // Android 10 and up manually deleting will still load the file causing crash -// var exists = checkUriExists(Uri.parse(audiobookUrl)) -// if (exists) { -// Log.d(tag, "Audiobook exists") -// audiobookFile = DocumentFileCompat.fromUri(context, Uri.parse(audiobookUrl)) -// } else { -// Log.e(tag, "Audiobook does not exist") -// } -// -// var coverExists = checkUriExists(Uri.parse(coverUrl)) -// if (coverExists) { -// Log.d(tag, "Cover Exists") -// coverFile = DocumentFileCompat.fromUri(context, Uri.parse(coverUrl)) -// } else if (coverUrl != null && coverUrl != "") { -// Log.e(tag, "Cover does not exist") -// } -// } -// -// if (audiobookFile == null) { -// Log.e(tag, "Audiobook was not found $audiobookUrl") -// } else { -// Log.d(tag, "Audiobook File Found StorageId:${audiobookFile.getStorageId(context)} | AbsolutePath:${audiobookFile.getAbsolutePath(context)} | BasePath:${audiobookFile.getBasePath(context)}") -// -// var _name = audiobookFile.name -// if (_name == null) _name = "" -// -// var size = audiobookFile.length() -// -// if (audiobookFile.uri.toString() !== audiobookUrl) { -// Log.d(tag, "Audiobook URI ${audiobookFile.uri} is different from $audiobookUrl => using the latter") -// } -// -// // Use existing URI's - bug happening where new uri is different from initial -// var abItem = AudiobookItem(Uri.parse(audiobookUrl), _name, size, coverUrl) -// -// Log.d(tag, "Setting AB ITEM ${abItem.name} | ${abItem.size} | ${abItem.uri} | ${abItem.coverUrl}") -// -// audiobookItems.add(abItem) -// } -// } -// -// Log.d(tag, "Load Finished ${audiobookItems.size} found") -// -// var audiobookObjs:List = audiobookItems.map{ it.toJSObject() } -// var mediaItemNoticePayload = JSObject() -// mediaItemNoticePayload.put("items", audiobookObjs) -// notifyListeners("onMediaLoaded", mediaItemNoticePayload) -// } - @PluginMethod - fun download(call: PluginCall) { - var audiobookId = call.data.getString("audiobookId", "audiobook").toString() - var url = call.data.getString("downloadUrl", "unknown").toString() - var coverDownloadUrl = call.data.getString("coverDownloadUrl", "").toString() - var title = call.data.getString("title", "Audiobook").toString() - var filename = call.data.getString("filename", "audiobook.mp3").toString() - var coverFilename = call.data.getString("coverFilename", "cover.png").toString() - var downloadFolderUrl = call.data.getString("downloadFolderUrl", "").toString() - var folder = DocumentFileCompat.fromUri(context, Uri.parse(downloadFolderUrl))!! - Log.d(tag, "Called download: $url | Folder: ${folder.name} | $downloadFolderUrl") + fun downloadLibraryItem(call: PluginCall) { + var libraryItemId = call.data.getString("libraryItemId").toString() + var localFolderId = call.data.getString("localFolderId").toString() + Log.d(tag, "Download library item $libraryItemId to folder $localFolderId") - var dlfilename = audiobookId + "." + File(filename).extension - var coverdlfilename = audiobookId + "." + File(coverFilename).extension - Log.d(tag, "DL Filename $dlfilename | Cover DL Filename $coverdlfilename") - - var canWriteToFolder = folder.canWrite() - if (!canWriteToFolder) { - Log.e(tag, "Error Cannot Write to Folder ${folder.baseName}") - val ret = JSObject() - ret.put("error", "Cannot write to ${folder.baseName}") - call.resolve(ret) - return - } - - var dlRequest = DownloadManager.Request(Uri.parse(url)) - dlRequest.setTitle("Ab: $title") - dlRequest.setDescription("Downloading to ${folder.name}") - dlRequest.setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED) - dlRequest.setDestinationInExternalPublicDir(Environment.DIRECTORY_DOWNLOADS, dlfilename) - - var audiobookDownloadId = downloadManager.enqueue(dlRequest) - var coverDownloadId:Long? = null - - if (coverDownloadUrl != "") { - var coverDlRequest = DownloadManager.Request(Uri.parse(coverDownloadUrl)) - coverDlRequest.setTitle("Cover: $title") - coverDlRequest.setDescription("Downloading to ${folder.name}") - coverDlRequest.setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_ONLY_COMPLETION) - coverDlRequest.setDestinationInExternalPublicDir(Environment.DIRECTORY_DOWNLOADS, coverdlfilename) - coverDownloadId = downloadManager.enqueue(coverDlRequest) - } - - var progressReceiver : (id:Long, prog: Long) -> Unit = { id:Long, prog: Long -> - if (id == audiobookDownloadId) { - var jsobj = JSObject() - jsobj.put("audiobookId", audiobookId) - jsobj.put("progress", prog) - notifyListeners("onDownloadProgress", jsobj) + apiHandler.getLibraryItem(libraryItemId) { libraryItem -> + Log.d(tag, "Got library item from server ${libraryItem.id}") + var localFolder = DeviceManager.dbManager.getLocalFolder(localFolderId) + if (localFolder != null) { + startLibraryItemDownload(libraryItem, localFolder) + call.resolve() } } - var coverDocFile:DocumentFile? = null - - var doneReceiver : (id:Long, success: Boolean) -> Unit = { id:Long, success: Boolean -> - Log.d(tag, "RECEIVER DONE $id, SUCCES? $success") - var docfile:DocumentFile? = null - - // Download was complete, now find downloaded file - if (id == coverDownloadId) { - docfile = DocumentFileCompat.fromPublicFolder(context, PublicDirectory.DOWNLOADS, coverdlfilename) - Log.d(tag, "Move Cover File ${docfile?.name}") - - // For unknown reason, Android 10 test was using the title set in "setTitle" for the dl manager as the filename - // check if this was the case - if (docfile?.name == null) { - docfile = DocumentFileCompat.fromPublicFolder(context, PublicDirectory.DOWNLOADS, "Cover: $title") - Log.d(tag, "Cover File name attempt 2 ${docfile?.name}") - } - } else if (id == audiobookDownloadId) { - docfile = DocumentFileCompat.fromPublicFolder(context, PublicDirectory.DOWNLOADS, dlfilename) - Log.d(tag, "Move Audiobook File ${docfile?.name}") - - if (docfile?.name == null) { - docfile = DocumentFileCompat.fromPublicFolder(context, PublicDirectory.DOWNLOADS, "Ab: $title") - Log.d(tag, "File name attempt 2 ${docfile?.name}") - } - } - - // Callback for moving the downloaded file - var callback = object : FileCallback() { - override fun onPrepare() { - Log.d(tag, "PREPARING MOVE FILE") - } - override fun onFailed(errorCode:ErrorCode) { - Log.e(tag, "FAILED MOVE FILE $errorCode") - - docfile?.delete() - coverDocFile?.delete() - - if (id == audiobookDownloadId) { - var jsobj = JSObject() - jsobj.put("audiobookId", audiobookId) - jsobj.put("error", "Move failed") - notifyListeners("onDownloadFailed", jsobj) - } - } - override fun onCompleted(result:Any) { - var resultDocFile = result as DocumentFile - var simplePath = resultDocFile.getSimplePath(context) - var storageId = resultDocFile.getStorageId(context) - var size = resultDocFile.length() - Log.d(tag, "Finished Moving File, NAME: ${resultDocFile.name} | URI:${resultDocFile.uri} | AbsolutePath:${resultDocFile.getAbsolutePath(context)} | $storageId | SimplePath: $simplePath") - - var abFolder = folder.findFolder(title) - var jsobj = JSObject() - jsobj.put("audiobookId", audiobookId) - jsobj.put("downloadId", id) - jsobj.put("storageId", storageId) - jsobj.put("storageType", resultDocFile.getStorageType(context)) - jsobj.put("folderUrl", abFolder?.uri) - jsobj.put("folderName", abFolder?.name) - jsobj.put("downloadFolderUrl", downloadFolderUrl) - jsobj.put("contentUrl", resultDocFile.uri) - jsobj.put("basePath", resultDocFile.getBasePath(context)) - jsobj.put("filename", filename) - jsobj.put("simplePath", simplePath) - jsobj.put("size", size) - - if (resultDocFile.name == filename) { - Log.d(tag, "Audiobook Finishing Moving") - } else if (resultDocFile.name == coverFilename) { - coverDocFile = docfile - Log.d(tag, "Audiobook Cover Finished Moving") - jsobj.put("isCover", true) - } - notifyListeners("onDownloadComplete", jsobj) - } - } - - // After file is downloaded, move the files into an audiobook directory inside the user selected folder - if (id == coverDownloadId) { - docfile?.moveFileTo(context, folder, FileDescription(coverFilename, title, MimeType.IMAGE), callback) - } else if (id == audiobookDownloadId) { - docfile?.moveFileTo(context, folder, FileDescription(filename, title, MimeType.AUDIO), callback) - } - } - - var progressUpdater = DownloadProgressUpdater(downloadManager, audiobookDownloadId, progressReceiver, doneReceiver) - progressUpdater.run() - if (coverDownloadId != null) { - var coverProgressUpdater = DownloadProgressUpdater(downloadManager, coverDownloadId, progressReceiver, doneReceiver) - coverProgressUpdater.run() - } - - val ret = JSObject() - ret.put("audiobookDownloadId", audiobookDownloadId) - ret.put("coverDownloadId", coverDownloadId) - call.resolve(ret) + call.resolve(JSObject("{\"error\":\"Library Item not found\"}")) } - internal class DownloadProgressUpdater(private val manager: DownloadManager, private val downloadId: Long, private var receiver: (Long, Long) -> Unit, private var doneReceiver: (Long, Boolean) -> Unit) : Thread() { - private val query: DownloadManager.Query = DownloadManager.Query() - private var totalBytes: Int = 0 - private var TAG = "DownloadProgressUpdater" + // Clean folder path so it can be used in URL + fun cleanRelPath(relPath: String): String { + var cleanedRelPath = relPath.replace("\\", "/").replace("%", "%25").replace("#", "%23") + return if (cleanedRelPath.startsWith("/")) cleanedRelPath.substring(1) else cleanedRelPath + } - init { - query.setFilterById(this.downloadId) + // Item filenames could be the same if they are in subfolders, this will make them unique + fun getFilenameFromRelPath(relPath: String): String { + var cleanedRelPath = relPath.replace("\\", "_").replace("/", "_") + return if (cleanedRelPath.startsWith("_")) cleanedRelPath.substring(1) else cleanedRelPath + } + + fun getAbMetadataText(libraryItem:LibraryItem):String { + var bookMedia = libraryItem.media as com.audiobookshelf.app.data.Book + var fileString = ";ABMETADATA1\n" + fileString += "#libraryItemId=${libraryItem.id}\n" + fileString += "title=${bookMedia.metadata.title}\n" + fileString += "author=${bookMedia.metadata.authorName}\n" + fileString += "narrator=${bookMedia.metadata.narratorName}\n" + fileString += "series=${bookMedia.metadata.seriesName}\n" + return fileString + } + + fun startLibraryItemDownload(libraryItem: LibraryItem, localFolder: LocalFolder) { + if (libraryItem.mediaType == "book") { + var bookMedia = libraryItem.media as com.audiobookshelf.app.data.Book + var bookTitle = bookMedia.metadata.title + var tracks = bookMedia.tracks ?: mutableListOf() + Log.d(tag, "Starting library item download with ${tracks.size} tracks") + var downloadItem = DownloadItem(libraryItem.id, localFolder, bookTitle, mutableListOf()) + var itemFolderPath = localFolder.absolutePath + "/" + bookTitle + tracks.forEach { audioFile -> + var serverPath = "/s/item/${libraryItem.id}/${cleanRelPath(audioFile.metadata.relPath)}" + var destinationFilename = getFilenameFromRelPath(audioFile.metadata.relPath) + Log.d(tag, "Audio File Server Path $serverPath | AF RelPath ${audioFile.metadata.relPath} | LocalFolder Path ${localFolder.absolutePath} | DestName ${destinationFilename}") + var destinationFile = File("$itemFolderPath/$destinationFilename") + var destinationUri = Uri.fromFile(destinationFile) + var downloadUri = Uri.parse("${apiHandler.serverUrl}${serverPath}?token=${apiHandler.token}") + Log.d(tag, "Audio File Destination Uri $destinationUri | Download URI $downloadUri") + var downloadItemPart = DownloadItemPart(UUID.randomUUID().toString(), destinationFilename, bookTitle, serverPath, localFolder.name + ?: "", localFolder.id, downloadUri, destinationUri, null, 0) + + downloadItem.downloadItemParts.add(downloadItemPart) + + var dlRequest = downloadItemPart.getDownloadRequest() + var downloadId = downloadManager.enqueue(dlRequest) + downloadItemPart.downloadId = downloadId + } + Log.d(tag, "Done queueing downloads ${downloadQueue.size}") + if (downloadItem.downloadItemParts.isNotEmpty()) { + // TODO: Cannot create new text file here but can download here... ?? +// var abmetadataFile = File(itemFolderPath, "abmetadata.abs") +// abmetadataFile.createNewFileIfPossible() +// abmetadataFile.writeText(getAbMetadataText(libraryItem)) + + downloadQueue.add(downloadItem) + startWatchingDownloads(downloadItem) + } + } else { + // TODO: Download podcast episode(s) } + } - override fun run() { - Log.d(TAG, "RUN FOR ID $downloadId") - var keepRunning = true - var increment = 0 - while (keepRunning) { - Thread.sleep(500) - increment++ + fun startWatchingDownloads(downloadItem: DownloadItem) { + GlobalScope.launch(Dispatchers.IO) { + while (downloadItem.downloadItemParts.isNotEmpty()) { + checkDownloads(downloadItem) + notifyListeners("onItemDownloadUpdate", JSObject(jacksonObjectMapper().writeValueAsString(downloadItem))) + delay(500) + } - if (increment % 4 == 0) { - Log.d(TAG, "Loop $increment : $downloadId") - } + var folderScanResult = folderScanner.scanForMediaItems(downloadItem.localFolder, false) - manager.query(query).use { + Log.d(tag, "Item download complete ${downloadItem.itemTitle}") + var jsobj = JSObject() + jsobj.put("libraryItemId", downloadItem.id) + jsobj.put("localFolderId", downloadItem.localFolder.id) + jsobj.put("folderScanResult", JSObject(jacksonObjectMapper().writeValueAsString(folderScanResult))) + notifyListeners("onItemDownloadComplete", jsobj) + } + } + + fun checkDownloads(downloadItem: DownloadItem) { + var itemParts = downloadItem.downloadItemParts.map { it } + Log.d(tag, "Check Downloads ${itemParts.size}") + for (downloadItemPart in itemParts) { + if (downloadItemPart.downloadId != null) { + var dlid = downloadItemPart.downloadId!! + val query = DownloadManager.Query().setFilterById(dlid) + downloadManager.query(query).use { if (it.moveToFirst()) { - //get total bytes of the file - if (totalBytes <= 0) { - totalBytes = it.getInt(it.getColumnIndex(DownloadManager.COLUMN_TOTAL_SIZE_BYTES)) - if (totalBytes <= 0) { - Log.e(TAG, "Download Is 0 Bytes $downloadId") - doneReceiver(downloadId, false) - keepRunning = false - this.interrupt() - return - } - } - + val totalBytes = it.getInt(it.getColumnIndex(DownloadManager.COLUMN_TOTAL_SIZE_BYTES)) + Log.d(tag, "Download ${downloadItemPart.name} bytes $totalBytes") val downloadStatus = it.getInt(it.getColumnIndex(DownloadManager.COLUMN_STATUS)) val bytesDownloadedSoFar = it.getInt(it.getColumnIndex(DownloadManager.COLUMN_BYTES_DOWNLOADED_SO_FAR)) - if (increment % 4 == 0) { - Log.d(TAG, "BYTES $increment : $downloadId : $bytesDownloadedSoFar : TOTAL: $totalBytes") - } - - if (downloadStatus == DownloadManager.STATUS_SUCCESSFUL || downloadStatus == DownloadManager.STATUS_FAILED) { - if (downloadStatus == DownloadManager.STATUS_SUCCESSFUL) { - doneReceiver(downloadId, true) - } else { - doneReceiver(downloadId, false) - } - keepRunning = false - this.interrupt() + if (downloadStatus == DownloadManager.STATUS_SUCCESSFUL) { + Log.d(tag, "Download ${downloadItemPart.name} Done") + downloadItem.downloadItemParts.remove(downloadItemPart) + } else if (downloadStatus == DownloadManager.STATUS_FAILED) { + Log.d(tag, "Download ${downloadItemPart.name} Failed") + downloadItem.downloadItemParts.remove(downloadItemPart) } else { //update progress - val percentProgress = ((bytesDownloadedSoFar * 100L) / totalBytes) - receiver(downloadId, percentProgress) + val percentProgress = if (totalBytes > 0) ((bytesDownloadedSoFar * 100L) / totalBytes) else 0 + Log.d(tag, "${downloadItemPart.name} Progress = $percentProgress%") + downloadItemPart.progress = percentProgress } } else { - Log.e(TAG, "NOT FOUND IN QUERY") - keepRunning = false + Log.d(tag, "Download ${downloadItemPart.name} not found in dlmanager") + downloadItem.downloadItemParts.remove(downloadItemPart) } } } } } } +// +// @PluginMethod +// fun download(call: PluginCall) { +// var audiobookId = call.data.getString("audiobookId", "audiobook").toString() +// var url = call.data.getString("downloadUrl", "unknown").toString() +// var coverDownloadUrl = call.data.getString("coverDownloadUrl", "").toString() +// var title = call.data.getString("title", "Audiobook").toString() +// var filename = call.data.getString("filename", "audiobook.mp3").toString() +// var coverFilename = call.data.getString("coverFilename", "cover.png").toString() +// var downloadFolderUrl = call.data.getString("downloadFolderUrl", "").toString() +// var folder = DocumentFileCompat.fromUri(context, Uri.parse(downloadFolderUrl))!! +// Log.d(tag, "Called download: $url | Folder: ${folder.name} | $downloadFolderUrl") +// +// var dlfilename = audiobookId + "." + File(filename).extension +// var coverdlfilename = audiobookId + "." + File(coverFilename).extension +// Log.d(tag, "DL Filename $dlfilename | Cover DL Filename $coverdlfilename") +// +// var canWriteToFolder = folder.canWrite() +// if (!canWriteToFolder) { +// Log.e(tag, "Error Cannot Write to Folder ${folder.baseName}") +// val ret = JSObject() +// ret.put("error", "Cannot write to ${folder.baseName}") +// call.resolve(ret) +// return +// } +// +// var dlRequest = DownloadManager.Request(Uri.parse(url)) +// dlRequest.setTitle("Ab: $title") +// dlRequest.setDescription("Downloading to ${folder.name}") +// dlRequest.setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED) +// dlRequest.setDestinationInExternalPublicDir(Environment.DIRECTORY_DOWNLOADS, dlfilename) +// +// var audiobookDownloadId = downloadManager.enqueue(dlRequest) +// var coverDownloadId:Long? = null +// +// if (coverDownloadUrl != "") { +// var coverDlRequest = DownloadManager.Request(Uri.parse(coverDownloadUrl)) +// coverDlRequest.setTitle("Cover: $title") +// coverDlRequest.setDescription("Downloading to ${folder.name}") +// coverDlRequest.setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_ONLY_COMPLETION) +// coverDlRequest.setDestinationInExternalPublicDir(Environment.DIRECTORY_DOWNLOADS, coverdlfilename) +// coverDownloadId = downloadManager.enqueue(coverDlRequest) +// } +// +// var progressReceiver : (id:Long, prog: Long) -> Unit = { id:Long, prog: Long -> +// if (id == audiobookDownloadId) { +// var jsobj = JSObject() +// jsobj.put("audiobookId", audiobookId) +// jsobj.put("progress", prog) +// notifyListeners("onDownloadProgress", jsobj) +// } +// } +// +// var coverDocFile:DocumentFile? = null +// +// var doneReceiver : (id:Long, success: Boolean) -> Unit = { id:Long, success: Boolean -> +// Log.d(tag, "RECEIVER DONE $id, SUCCES? $success") +// var docfile:DocumentFile? = null +// +// // Download was complete, now find downloaded file +// if (id == coverDownloadId) { +// docfile = DocumentFileCompat.fromPublicFolder(context, PublicDirectory.DOWNLOADS, coverdlfilename) +// Log.d(tag, "Move Cover File ${docfile?.name}") +// +// // For unknown reason, Android 10 test was using the title set in "setTitle" for the dl manager as the filename +// // check if this was the case +// if (docfile?.name == null) { +// docfile = DocumentFileCompat.fromPublicFolder(context, PublicDirectory.DOWNLOADS, "Cover: $title") +// Log.d(tag, "Cover File name attempt 2 ${docfile?.name}") +// } +// } else if (id == audiobookDownloadId) { +// docfile = DocumentFileCompat.fromPublicFolder(context, PublicDirectory.DOWNLOADS, dlfilename) +// Log.d(tag, "Move Audiobook File ${docfile?.name}") +// +// if (docfile?.name == null) { +// docfile = DocumentFileCompat.fromPublicFolder(context, PublicDirectory.DOWNLOADS, "Ab: $title") +// Log.d(tag, "File name attempt 2 ${docfile?.name}") +// } +// } +// +// // Callback for moving the downloaded file +// var callback = object : FileCallback() { +// override fun onPrepare() { +// Log.d(tag, "PREPARING MOVE FILE") +// } +// override fun onFailed(errorCode:ErrorCode) { +// Log.e(tag, "FAILED MOVE FILE $errorCode") +// +// docfile?.delete() +// coverDocFile?.delete() +// +// if (id == audiobookDownloadId) { +// var jsobj = JSObject() +// jsobj.put("audiobookId", audiobookId) +// jsobj.put("error", "Move failed") +// notifyListeners("onDownloadFailed", jsobj) +// } +// } +// override fun onCompleted(result:Any) { +// var resultDocFile = result as DocumentFile +// var simplePath = resultDocFile.getSimplePath(context) +// var storageId = resultDocFile.getStorageId(context) +// var size = resultDocFile.length() +// Log.d(tag, "Finished Moving File, NAME: ${resultDocFile.name} | URI:${resultDocFile.uri} | AbsolutePath:${resultDocFile.getAbsolutePath(context)} | $storageId | SimplePath: $simplePath") +// +// var abFolder = folder.findFolder(title) +// var jsobj = JSObject() +// jsobj.put("audiobookId", audiobookId) +// jsobj.put("downloadId", id) +// jsobj.put("storageId", storageId) +// jsobj.put("storageType", resultDocFile.getStorageType(context)) +// jsobj.put("folderUrl", abFolder?.uri) +// jsobj.put("folderName", abFolder?.name) +// jsobj.put("downloadFolderUrl", downloadFolderUrl) +// jsobj.put("contentUrl", resultDocFile.uri) +// jsobj.put("basePath", resultDocFile.getBasePath(context)) +// jsobj.put("filename", filename) +// jsobj.put("simplePath", simplePath) +// jsobj.put("size", size) +// +// if (resultDocFile.name == filename) { +// Log.d(tag, "Audiobook Finishing Moving") +// } else if (resultDocFile.name == coverFilename) { +// coverDocFile = docfile +// Log.d(tag, "Audiobook Cover Finished Moving") +// jsobj.put("isCover", true) +// } +// notifyListeners("onDownloadComplete", jsobj) +// } +// } +// +// // After file is downloaded, move the files into an audiobook directory inside the user selected folder +// if (id == coverDownloadId) { +// docfile?.moveFileTo(context, folder, FileDescription(coverFilename, title, MimeType.IMAGE), callback) +// } else if (id == audiobookDownloadId) { +// docfile?.moveFileTo(context, folder, FileDescription(filename, title, MimeType.AUDIO), callback) +// } +// } +// +// var progressUpdater = DownloadProgressUpdater(downloadManager, audiobookDownloadId, progressReceiver, doneReceiver) +// progressUpdater.run() +// if (coverDownloadId != null) { +// var coverProgressUpdater = DownloadProgressUpdater(downloadManager, coverDownloadId, progressReceiver, doneReceiver) +// coverProgressUpdater.run() +// } +// +// val ret = JSObject() +// ret.put("audiobookDownloadId", audiobookDownloadId) +// ret.put("coverDownloadId", coverDownloadId) +// call.resolve(ret) +// } +// +// +//internal class DownloadProgressUpdater(private val manager: DownloadManager, private val downloadId: Long, private var receiver: (Long, Long) -> Unit, private var doneReceiver: (Long, Boolean) -> Unit) : Thread() { +// private val query: DownloadManager.Query = DownloadManager.Query() +// private var totalBytes: Int = 0 +// private var TAG = "DownloadProgressUpdater" +// +// init { +// query.setFilterById(this.downloadId) +// } +// +// override fun run() { +// Log.d(TAG, "RUN FOR ID $downloadId") +// var keepRunning = true +// var increment = 0 +// while (keepRunning) { +// Thread.sleep(500) +// increment++ +// +// if (increment % 4 == 0) { +// Log.d(TAG, "Loop $increment : $downloadId") +// } +// +// manager.query(query).use { +// if (it.moveToFirst()) { +// //get total bytes of the file +// if (totalBytes <= 0) { +// totalBytes = it.getInt(it.getColumnIndex(DownloadManager.COLUMN_TOTAL_SIZE_BYTES)) +// if (totalBytes <= 0) { +// Log.e(TAG, "Download Is 0 Bytes $downloadId") +// doneReceiver(downloadId, false) +// keepRunning = false +// this.interrupt() +// return +// } +// } +// +// val downloadStatus = it.getInt(it.getColumnIndex(DownloadManager.COLUMN_STATUS)) +// val bytesDownloadedSoFar = it.getInt(it.getColumnIndex(DownloadManager.COLUMN_BYTES_DOWNLOADED_SO_FAR)) +// +// if (increment % 4 == 0) { +// Log.d(TAG, "BYTES $increment : $downloadId : $bytesDownloadedSoFar : TOTAL: $totalBytes") +// } +// +// if (downloadStatus == DownloadManager.STATUS_SUCCESSFUL || downloadStatus == DownloadManager.STATUS_FAILED) { +// if (downloadStatus == DownloadManager.STATUS_SUCCESSFUL) { +// doneReceiver(downloadId, true) +// } else { +// doneReceiver(downloadId, false) +// } +// keepRunning = false +// this.interrupt() +// } else { +// //update progress +// val percentProgress = ((bytesDownloadedSoFar * 100L) / totalBytes) +// receiver(downloadId, percentProgress) +// } +// } else { +// Log.e(TAG, "NOT FOUND IN QUERY") +// keepRunning = false +// } +// } +// } +// } +// } +//} diff --git a/android/app/src/main/java/com/audiobookshelf/app/MainActivity.kt b/android/app/src/main/java/com/audiobookshelf/app/MainActivity.kt index b74a6cef..189fda47 100644 --- a/android/app/src/main/java/com/audiobookshelf/app/MainActivity.kt +++ b/android/app/src/main/java/com/audiobookshelf/app/MainActivity.kt @@ -52,6 +52,9 @@ class MainActivity : BridgeActivity() { Log.d(tag, "onCreate") +// var ss = SimpleStorage(this) +// ss.requestFullStorageAccess() + var permission = ActivityCompat.checkSelfPermission(this, Manifest.permission.READ_EXTERNAL_STORAGE) if (permission != PackageManager.PERMISSION_GRANTED) { ActivityCompat.requestPermissions(this, diff --git a/android/app/src/main/java/com/audiobookshelf/app/PlayerNotificationService.kt b/android/app/src/main/java/com/audiobookshelf/app/PlayerNotificationService.kt index 7739c05d..bc066ec2 100644 --- a/android/app/src/main/java/com/audiobookshelf/app/PlayerNotificationService.kt +++ b/android/app/src/main/java/com/audiobookshelf/app/PlayerNotificationService.kt @@ -381,10 +381,10 @@ class PlayerNotificationService : MediaBrowserServiceCompat() { .setMediaId(currentPlaybackSession!!.id) .setTitle(currentPlaybackSession!!.displayTitle) .setSubtitle(currentPlaybackSession!!.displayAuthor) - .setMediaUri(currentPlaybackSession!!.getContentUri()) .setIconUri(currentPlaybackSession!!.getCoverUri()) return builder.build() } + // .setMediaUri(currentPlaybackSession!!.getContentUri()) } val myPlaybackPreparer:MediaSessionConnector.PlaybackPreparer = object :MediaSessionConnector.PlaybackPreparer { @@ -661,7 +661,6 @@ class PlayerNotificationService : MediaBrowserServiceCompat() { } } - /* User callable methods */ @@ -672,28 +671,44 @@ class PlayerNotificationService : MediaBrowserServiceCompat() { var metadata = playbackSession.getMediaMetadataCompat() mediaSession.setMetadata(metadata) - var mediaMetadata = playbackSession.getExoMediaMetadata() - var mediaUri = playbackSession.getContentUri() - var mimeType = playbackSession.getMimeType() - var mediaItem = MediaItem.Builder().setUri(mediaUri).setMediaMetadata(mediaMetadata).setMimeType(mimeType).build() + var mediaItems = playbackSession.getMediaItems() if (mPlayer == currentPlayer) { var mediaSource:MediaSource - if (!playbackSession.isHLS) { - Log.d(tag, "Playing Local File") + if (playbackSession.isLocal) { + Log.d(tag, "Playing Local Item") var dataSourceFactory = DefaultDataSourceFactory(ctx, channelId) - mediaSource = ProgressiveMediaSource.Factory(dataSourceFactory).createMediaSource(mediaItem) + mediaSource = ProgressiveMediaSource.Factory(dataSourceFactory).createMediaSource(mediaItems[0]) + } else if (!playbackSession.isHLS) { + Log.d(tag, "Direct Playing Item") + var dataSourceFactory = DefaultDataSourceFactory(ctx, channelId) + mediaSource = ProgressiveMediaSource.Factory(dataSourceFactory).createMediaSource(mediaItems[0]) } else { - Log.d(tag, "Playing HLS File") + Log.d(tag, "Playing HLS Item") var dataSourceFactory = DefaultHttpDataSource.Factory() dataSourceFactory.setUserAgent(channelId) dataSourceFactory.setDefaultRequestProperties(hashMapOf("Authorization" to "Bearer ${playbackSession.token}")) - mediaSource = HlsMediaSource.Factory(dataSourceFactory).createMediaSource(mediaItem) + mediaSource = HlsMediaSource.Factory(dataSourceFactory).createMediaSource(mediaItems[0]) } - Log.d(tag, "Playback Session CURRENT TIME ${playbackSession.currentTime} | ${playbackSession.currentTimeMs}") - mPlayer.setMediaSource(mediaSource, playbackSession.currentTimeMs) + mPlayer.setMediaSource(mediaSource) + + // Add remaining media items if multi-track + if (mediaItems.size > 1) { + mPlayer.addMediaItems(mediaItems.subList(1, mediaItems.size)) + Log.d(tag, "mPlayer total media items ${mPlayer.mediaItemCount}") + + var currentTrackIndex = playbackSession.getCurrentTrackIndex() + var currentTrackTime = playbackSession.getCurrentTrackTimeMs() + Log.d(tag, "mPlayer current track index $currentTrackIndex & current track time $currentTrackTime") + mPlayer.seekTo(currentTrackIndex, currentTrackTime) + } else { + mPlayer.seekTo(playbackSession.currentTimeMs) + } + + + } else if (castPlayer != null) { //// var mediaQueue = currentAudiobookStreamData!!.getCastQueue() // // TODO: Start position will need to be adjusted if using multi-track queue @@ -784,11 +799,23 @@ class PlayerNotificationService : MediaBrowserServiceCompat() { } fun getCurrentTime() : Long { - return currentPlayer.currentPosition + if (currentPlayer.mediaItemCount > 1) { + var windowIndex = currentPlayer.currentWindowIndex + var currentTrackStartOffset = currentPlaybackSession?.getTrackStartOffsetMs(windowIndex) ?: 0L + return currentPlayer.currentPosition + currentTrackStartOffset + } else { + return currentPlayer.currentPosition + } } fun getBufferedTime() : Long { - return currentPlayer.bufferedPosition + if (currentPlayer.mediaItemCount > 1) { + var windowIndex = currentPlayer.currentWindowIndex + var currentTrackStartOffset = currentPlaybackSession?.getTrackStartOffsetMs(windowIndex) ?: 0L + return currentPlayer.bufferedPosition + currentTrackStartOffset + } else { + return currentPlayer.bufferedPosition + } } fun getTheLastPauseTime() : Long { @@ -867,7 +894,14 @@ class PlayerNotificationService : MediaBrowserServiceCompat() { } fun seekPlayer(time: Long) { - currentPlayer.seekTo(time) + if (currentPlayer.mediaItemCount > 1) { + currentPlaybackSession?.currentTime = time / 1000.0 + var newWindowIndex = currentPlaybackSession?.getCurrentTrackIndex() ?: 0 + var newTimeOffset = currentPlaybackSession?.getCurrentTrackTimeMs() ?: 0 + currentPlayer.seekTo(newWindowIndex, newTimeOffset) + } else { + currentPlayer.seekTo(time) + } } fun seekForward(amount: Long) { @@ -883,9 +917,6 @@ class PlayerNotificationService : MediaBrowserServiceCompat() { } fun terminateStream() { -// if (currentPlayer.playbackState == Player.STATE_READY) { -// currentPlayer.clearMediaItems() -// } currentPlayer.clearMediaItems() currentPlaybackSession = null lastPauseTime = 0 @@ -894,15 +925,13 @@ class PlayerNotificationService : MediaBrowserServiceCompat() { fun sendClientMetadata(stateName: String) { var metadata = JSObject() - var duration = mPlayer.duration - if (duration < 0) duration = 0 + var duration = currentPlaybackSession?.getTotalDuration() ?: 0 metadata.put("duration", duration) - metadata.put("currentTime", mPlayer.currentPosition) + metadata.put("currentTime", getCurrentTime()) metadata.put("stateName", stateName) clientEventEmitter?.onMetadata(metadata) } - // // MEDIA BROWSER STUFF (ANDROID AUTO) // diff --git a/android/app/src/main/java/com/audiobookshelf/app/data/DataClasses.kt b/android/app/src/main/java/com/audiobookshelf/app/data/DataClasses.kt index b24b1b18..160df619 100644 --- a/android/app/src/main/java/com/audiobookshelf/app/data/DataClasses.kt +++ b/android/app/src/main/java/com/audiobookshelf/app/data/DataClasses.kt @@ -47,7 +47,10 @@ data class Book( var coverPath:String?, var tags:MutableList, var audioFiles:MutableList, - var chapters:MutableList + var chapters:MutableList, + var tracks:MutableList?, + var size:Long?, + var duration:Double? ) : MediaType() // This auto-detects whether it is a Book or Podcast @@ -154,7 +157,15 @@ data class AudioTrack( var isLocal:Boolean, var localFileId:String?, var audioProbeResult:AudioProbeResult? -) +) { + + @get:JsonIgnore + val startOffsetMs get() = (startOffset * 1000L).toLong() + @get:JsonIgnore + val durationMs get() = (duration * 1000L).toLong() + @get:JsonIgnore + val endOffsetMs get() = startOffsetMs + durationMs +} @JsonIgnoreProperties(ignoreUnknown = true) data class BookChapter( diff --git a/android/app/src/main/java/com/audiobookshelf/app/data/DbManager.kt b/android/app/src/main/java/com/audiobookshelf/app/data/DbManager.kt index 6358b435..ada9639b 100644 --- a/android/app/src/main/java/com/audiobookshelf/app/data/DbManager.kt +++ b/android/app/src/main/java/com/audiobookshelf/app/data/DbManager.kt @@ -1,6 +1,7 @@ package com.audiobookshelf.app.data import android.util.Log +import androidx.documentfile.provider.DocumentFile import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper import com.getcapacitor.JSObject import com.getcapacitor.Plugin @@ -12,6 +13,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.launch import org.json.JSONObject +import java.io.File @CapacitorPlugin(name = "DbManager") class DbManager : Plugin() { @@ -25,14 +27,28 @@ class DbManager : Plugin() { Paper.book("device").write("data", deviceData) } - fun loadLocalMediaItems():List { + fun loadLocalMediaItems():MutableList { var localMediaItems:MutableList = mutableListOf() Paper.book("localMediaItems").allKeys.forEach { var localMediaItem:LocalMediaItem? = Paper.book("localMediaItems").read(it) if (localMediaItem != null) { - localMediaItems.add(localMediaItem) +// if (localMediaItem.coverContentUrl != null) { +// var file = DocumentFile.fromSingleUri(ctx) +// if (!file.exists()) { +// Log.e(tag, "Local media item cover url does not exist ${localMediaItem.coverContentUrl}") +// removeLocalMediaItem(localMediaItem.id) +// } else { +// localMediaItems.add(localMediaItem) +// } +// } else { + localMediaItems.add(localMediaItem) +// } } } +// localMediaItems = localMediaItems.filter { +// +// file.exists() +// } return localMediaItems } diff --git a/android/app/src/main/java/com/audiobookshelf/app/data/DeviceClasses.kt b/android/app/src/main/java/com/audiobookshelf/app/data/DeviceClasses.kt index 2fcc3669..afa63ea3 100644 --- a/android/app/src/main/java/com/audiobookshelf/app/data/DeviceClasses.kt +++ b/android/app/src/main/java/com/audiobookshelf/app/data/DeviceClasses.kt @@ -29,7 +29,8 @@ data class LocalMediaItem( var absolutePath:String, var audioTracks:MutableList, var localFiles:MutableList, - var coverContentUrl:String? + var coverContentUrl:String?, + var coverAbsolutePath:String? ) { @JsonIgnore diff --git a/android/app/src/main/java/com/audiobookshelf/app/data/PlaybackSession.kt b/android/app/src/main/java/com/audiobookshelf/app/data/PlaybackSession.kt index dcafef24..b9d26e20 100644 --- a/android/app/src/main/java/com/audiobookshelf/app/data/PlaybackSession.kt +++ b/android/app/src/main/java/com/audiobookshelf/app/data/PlaybackSession.kt @@ -2,9 +2,11 @@ package com.audiobookshelf.app.data import android.net.Uri import android.support.v4.media.MediaMetadataCompat +import android.util.Log import com.audiobookshelf.app.R import com.fasterxml.jackson.annotation.JsonIgnore import com.fasterxml.jackson.annotation.JsonIgnoreProperties +import com.google.android.exoplayer2.MediaItem import com.google.android.exoplayer2.MediaMetadata // TODO: enum or something in kotlin? @@ -39,6 +41,37 @@ class PlaybackSession( val isLocal get() = playMethod == PLAYMETHOD_LOCAL val currentTimeMs get() = (currentTime * 1000L).toLong() + @JsonIgnore + fun getCurrentTrackIndex():Int { + for (i in 0..(audioTracks.size - 1)) { + var track = audioTracks[i] + if (currentTimeMs >= track.startOffsetMs && (track.endOffsetMs) > currentTimeMs) { + return i + } + } + return audioTracks.size - 1 + } + + @JsonIgnore + fun getCurrentTrackTimeMs():Long { + var currentTrack = audioTracks[this.getCurrentTrackIndex()] + var time = currentTime - currentTrack.startOffset + return (time * 1000L).toLong() + } + + @JsonIgnore + fun getTrackStartOffsetMs(index:Int):Long { + var currentTrack = audioTracks[index] + return (currentTrack.startOffset * 1000L).toLong() + } + + @JsonIgnore + fun getTotalDuration():Double { + var total = 0.0 + audioTracks.forEach { total += it.duration } + return total + } + @JsonIgnore fun getCoverUri(): Uri { if (localMediaItem?.coverContentUrl != null) return Uri.parse(localMediaItem?.coverContentUrl) ?: Uri.parse("android.resource://com.audiobookshelf.app/" + R.drawable.icon) @@ -48,18 +81,11 @@ class PlaybackSession( } @JsonIgnore - fun getContentUri(): Uri { - var audioTrack = audioTracks[0] + fun getContentUri(audioTrack:AudioTrack): Uri { if (isLocal) return Uri.parse(audioTrack.contentUrl) // Local content url return Uri.parse("$serverUrl${audioTrack.contentUrl}?token=$token") } - @JsonIgnore - fun getMimeType():String { - var audioTrack = audioTracks[0] - return audioTrack.mimeType - } - @JsonIgnore fun getMediaMetadataCompat(): MediaMetadataCompat { var metadataBuilder = MediaMetadataCompat.Builder() @@ -74,7 +100,7 @@ class PlaybackSession( } @JsonIgnore - fun getExoMediaMetadata(): MediaMetadata { + fun getExoMediaMetadata(audioTrack:AudioTrack): MediaMetadata { var metadataBuilder = MediaMetadata.Builder() .setTitle(displayTitle) .setDisplayTitle(displayTitle) @@ -82,9 +108,23 @@ class PlaybackSession( .setAlbumArtist(displayAuthor) .setSubtitle(displayAuthor) - var contentUri = this.getContentUri() + var contentUri = this.getContentUri(audioTrack) metadataBuilder.setMediaUri(contentUri) return metadataBuilder.build() } + + @JsonIgnore + fun getMediaItems():List { + var mediaItems:MutableList = mutableListOf() + + for (audioTrack in audioTracks) { + var mediaMetadata = this.getExoMediaMetadata(audioTrack) + var mediaUri = this.getContentUri(audioTrack) + var mimeType = audioTrack.mimeType + var mediaItem = MediaItem.Builder().setUri(mediaUri).setMediaMetadata(mediaMetadata).setMimeType(mimeType).build() + mediaItems.add(mediaItem) + } + return mediaItems + } } 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 d58901f9..b2cdf181 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 @@ -73,6 +73,7 @@ class FolderScanner(var ctx: Context) { var index = 1 var startOffset = 0.0 var coverContentUrl:String? = null + var coverAbsolutePath:String? = null var filesInFolder = it.search(false, DocumentFileType.FILE, arrayOf("audio/*", "image/*")) @@ -157,6 +158,7 @@ class FolderScanner(var ctx: Context) { // First image file use as cover path if (coverContentUrl == null) { coverContentUrl = localFile.contentUrl + coverAbsolutePath = localFile.absolutePath } } } @@ -173,7 +175,7 @@ class FolderScanner(var ctx: Context) { else mediaItemsAdded++ Log.d(tag, "Found local media item named $itemFolderName with ${audioTracks.size} tracks and ${localFiles.size} local files") - var localMediaItem = LocalMediaItem(itemId, itemFolderName, localFolder.mediaType, localFolder.id, it.uri.toString(), it.getSimplePath(ctx), it.getAbsolutePath(ctx),audioTracks,localFiles,coverContentUrl) + var localMediaItem = LocalMediaItem(itemId, itemFolderName, localFolder.mediaType, localFolder.id, it.uri.toString(), it.getSimplePath(ctx), it.getAbsolutePath(ctx),audioTracks,localFiles,coverContentUrl,coverAbsolutePath) mediaItems.add(localMediaItem) } } diff --git a/android/app/src/main/java/com/audiobookshelf/app/server/ApiHandler.kt b/android/app/src/main/java/com/audiobookshelf/app/server/ApiHandler.kt index cb71f6ac..c7e02e10 100644 --- a/android/app/src/main/java/com/audiobookshelf/app/server/ApiHandler.kt +++ b/android/app/src/main/java/com/audiobookshelf/app/server/ApiHandler.kt @@ -94,14 +94,20 @@ class ApiHandler { } } + fun getLibraryItem(libraryItemId:String, cb: (LibraryItem) -> Unit) { + getRequest("/api/items/$libraryItemId?expanded=1") { + val libraryItem = jacksonObjectMapper().readValue(it.toString()) + cb(libraryItem) + } + } + fun getLibraryItems(libraryId:String, cb: (List) -> Unit) { - val mapper = jacksonObjectMapper() getRequest("/api/libraries/$libraryId/items") { val items = mutableListOf() if (it.has("results")) { var array = it.getJSONArray("results") for (i in 0 until array.length()) { - val item = mapper.readValue(array.get(i).toString()) + val item = jacksonObjectMapper().readValue(array.get(i).toString()) items.add(item) } } diff --git a/components/app/AudioPlayer.vue b/components/app/AudioPlayer.vue index 7b38f251..2445fc01 100644 --- a/components/app/AudioPlayer.vue +++ b/components/app/AudioPlayer.vue @@ -291,8 +291,8 @@ export default { return this.restart() } - // If 1 second or less into current chapter, then go to previous - if (this.currentTime - this.currentChapter.start <= 1) { + // If 4 seconds or less into current chapter, then go to previous + if (this.currentTime - this.currentChapter.start <= 4) { var currChapterIndex = this.chapters.findIndex((ch) => Number(ch.start) <= this.currentTime && Number(ch.end) >= this.currentTime) if (currChapterIndex > 0) { var prevChapter = this.chapters[currChapterIndex - 1] @@ -509,8 +509,8 @@ export default { console.log('onMetadata', JSON.stringify(data)) this.isLoading = false - this.totalDuration = Number((data.duration / 1000).toFixed(2)) - this.$emit('setTotalDuration', this.totalDuration) + // this.totalDuration = Number((data.duration / 1000).toFixed(2)) + this.totalDuration = Number(data.duration.toFixed(2)) this.currentTime = Number((data.currentTime / 1000).toFixed(2)) this.stateName = data.stateName diff --git a/components/app/AudioPlayerContainer.vue b/components/app/AudioPlayerContainer.vue index ebd3b4be..52877c4a 100644 --- a/components/app/AudioPlayerContainer.vue +++ b/components/app/AudioPlayerContainer.vue @@ -1,19 +1,7 @@ @@ -81,7 +83,8 @@ export default { }, data() { return { - resettingProgress: false + resettingProgress: false, + showSelectLocalFolder: false } }, computed: { @@ -97,6 +100,9 @@ export default { libraryItemId() { return this.libraryItem.id }, + mediaType() { + return this.libraryItem.mediaType + }, media() { return this.libraryItem.media || {} }, @@ -235,17 +241,6 @@ export default { this.resettingProgress = false }) } - // if (value) { - // this.resettingProgress = true - // this.$store.dispatch('user/updateUserAudiobookData', { - // libraryItemId: this.libraryItemId, - // currentTime: 0, - // totalDuration: this.duration, - // progress: 0, - // lastUpdate: Date.now(), - // isRead: false - // }) - // } }, itemUpdated(libraryItem) { if (libraryItem.id === this.libraryItemId) { @@ -253,14 +248,74 @@ export default { this.libraryItem = libraryItem } }, + async selectFolder() { + // Select and save the local folder for media type + var folderObj = await StorageManager.selectFolder({ mediaType: this.mediaType }) + if (folderObj.error) { + return this.$toast.error(`Error: ${folderObj.error || 'Unknown Error'}`) + } + return folderObj + }, + selectedLocalFolder(localFolder) { + this.showSelectLocalFolder = false + this.download(localFolder) + }, downloadClick() { + this.download() + }, + async download(selectedLocalFolder = null) { console.log('downloadClick ' + this.$server.connected + ' | ' + !!this.downloadObj) if (!this.$server.connected) return - if (this.downloadObj) { - console.log('Already downloaded', this.downloadObj) - } else { - this.prepareDownload() + if (!this.numTracks || this.downloadObj) { + return + } + + // Get the local folder to download to + var localFolder = selectedLocalFolder + if (!localFolder) { + var localFolders = (await this.$db.loadFolders()) || [] + console.log('Local folders loaded', localFolders.length) + var foldersWithMediaType = localFolders.filter((lf) => { + console.log('Checking local folder', lf.mediaType) + return lf.mediaType == this.mediaType + }) + console.log('Folders with media type', this.mediaType, foldersWithMediaType.length) + if (!foldersWithMediaType.length) { + // No local folders or no local folders with this media type + localFolder = await this.selectFolder() + } else if (foldersWithMediaType.length == 1) { + console.log('Only 1 local folder with this media type - auto select it') + localFolder = foldersWithMediaType[0] + } else { + console.log('Multiple folders with media type') + this.showSelectLocalFolder = true + return + } + if (!localFolder) { + return this.$toast.error('Invalid download folder') + } + } + + console.log('Local folder', JSON.stringify(localFolder)) + + var startDownloadMessage = `Start download for "${this.title}" with ${this.numTracks} audio track${this.numTracks == 1 ? '' : 's'} to folder ${localFolder.name}?` + const { value } = await Dialog.confirm({ + title: 'Confirm', + message: startDownloadMessage + }) + if (value) { + this.startDownload(localFolder) + } + }, + async startDownload(localFolder) { + console.log('Starting download to local folder', localFolder.name) + var downloadRes = await AudioDownloader.downloadLibraryItem({ libraryItemId: this.libraryItemId, localFolderId: localFolder.id }) + if (downloadRes.error) { + var errorMsg = downloadRes.error || 'Unknown error' + console.error('Download error', errorMsg) + this.$toast.update(download.toastId, { content: `Error: ${errorMsg}`, options: { timeout: 5000, type: 'error' } }) + this.$store.commit('downloads/removeDownload', download) } }, async changeDownloadFolderClick() { @@ -286,103 +341,103 @@ export default { await this.$localStore.setDownloadFolder(folderObj) } }, - async prepareDownload() { - var audiobook = this.libraryItem - if (!audiobook) { - return - } + // async prepareDownload() { + // var audiobook = this.libraryItem + // if (!audiobook) { + // return + // } - // Download Path - var dlFolder = this.$localStore.downloadFolder - console.log('Prepare download: ' + this.hasStoragePermission + ' | ' + dlFolder) + // // Download Path + // var dlFolder = this.$localStore.downloadFolder + // console.log('Prepare download: ' + this.hasStoragePermission + ' | ' + dlFolder) - if (!this.hasStoragePermission || !dlFolder) { - console.log('No download folder, request from user') - // User to select download folder from download modal to ensure permissions - // this.$store.commit('downloads/setShowModal', true) - this.changeDownloadFolderClick() - return - } else { - console.log('Has Download folder: ' + JSON.stringify(dlFolder)) - } + // if (!this.hasStoragePermission || !dlFolder) { + // console.log('No download folder, request from user') + // // User to select download folder from download modal to ensure permissions + // // this.$store.commit('downloads/setShowModal', true) + // this.changeDownloadFolderClick() + // return + // } else { + // console.log('Has Download folder: ' + JSON.stringify(dlFolder)) + // } - var downloadObject = { - id: this.libraryItemId, - downloadFolderUrl: dlFolder.uri, - audiobook: { - ...audiobook - }, - isPreparing: true, - isDownloading: false, - toastId: this.$toast(`Preparing download for "${this.title}"`, { timeout: false }) - } - if (audiobook.tracks.length === 1) { - // Single track should not need preparation - console.log('Single track, start download no prep needed') - var track = audiobook.tracks[0] - var fileext = track.ext + // var downloadObject = { + // id: this.libraryItemId, + // downloadFolderUrl: dlFolder.uri, + // audiobook: { + // ...audiobook + // }, + // isPreparing: true, + // isDownloading: false, + // toastId: this.$toast(`Preparing download for "${this.title}"`, { timeout: false }) + // } + // if (audiobook.tracks.length === 1) { + // // Single track should not need preparation + // console.log('Single track, start download no prep needed') + // var track = audiobook.tracks[0] + // var fileext = track.ext - console.log('Download Single Track Path: ' + track.path) + // console.log('Download Single Track Path: ' + track.path) - var relTrackPath = track.path.replace('\\', '/').replace(this.libraryItem.path.replace('\\', '/'), '') + // var relTrackPath = track.path.replace('\\', '/').replace(this.libraryItem.path.replace('\\', '/'), '') - var url = `${this.$store.state.serverUrl}/s/book/${this.libraryItemId}${relTrackPath}?token=${this.userToken}` - this.startDownload(url, fileext, downloadObject) - } else { - // Multi-track merge - this.$store.commit('downloads/addUpdateDownload', downloadObject) + // var url = `${this.$store.state.serverUrl}/s/book/${this.libraryItemId}${relTrackPath}?token=${this.userToken}` + // this.startDownload(url, fileext, downloadObject) + // } else { + // // Multi-track merge + // this.$store.commit('downloads/addUpdateDownload', downloadObject) - var prepareDownloadPayload = { - audiobookId: this.libraryItemId, - audioFileType: 'same', - type: 'singleAudio' - } - this.$server.socket.emit('download', prepareDownloadPayload) - } - }, - getCoverUrlForDownload() { - if (!this.book || !this.book.cover) return null + // var prepareDownloadPayload = { + // audiobookId: this.libraryItemId, + // audioFileType: 'same', + // type: 'singleAudio' + // } + // this.$server.socket.emit('download', prepareDownloadPayload) + // } + // }, + // getCoverUrlForDownload() { + // if (!this.book || !this.book.cover) return null - var cover = this.book.cover - if (cover.startsWith('http')) return cover - var coverSrc = this.$store.getters['global/getLibraryItemCoverSrc'](this.libraryItem) - return coverSrc - }, - async startDownload(url, fileext, download) { - this.$toast.update(download.toastId, { content: `Downloading "${download.audiobook.book.title}"...` }) + // var cover = this.book.cover + // if (cover.startsWith('http')) return cover + // var coverSrc = this.$store.getters['global/getLibraryItemCoverSrc'](this.libraryItem) + // return coverSrc + // }, + // async startDownload(url, fileext, download) { + // this.$toast.update(download.toastId, { content: `Downloading "${download.audiobook.book.title}"...` }) - var coverDownloadUrl = this.getCoverUrlForDownload() - var coverFilename = null - if (coverDownloadUrl) { - var coverNoQueryString = coverDownloadUrl.split('?')[0] + // var coverDownloadUrl = this.getCoverUrlForDownload() + // var coverFilename = null + // if (coverDownloadUrl) { + // var coverNoQueryString = coverDownloadUrl.split('?')[0] - var coverExt = Path.extname(coverNoQueryString) || '.jpg' - coverFilename = `cover-${download.id}${coverExt}` - } + // var coverExt = Path.extname(coverNoQueryString) || '.jpg' + // coverFilename = `cover-${download.id}${coverExt}` + // } - download.isDownloading = true - download.isPreparing = false - download.filename = `${download.audiobook.book.title}${fileext}` - this.$store.commit('downloads/addUpdateDownload', download) + // download.isDownloading = true + // download.isPreparing = false + // download.filename = `${download.audiobook.book.title}${fileext}` + // this.$store.commit('downloads/addUpdateDownload', download) - console.log('Starting Download URL', url) - var downloadRequestPayload = { - audiobookId: download.id, - filename: download.filename, - coverFilename, - coverDownloadUrl, - downloadUrl: url, - title: download.audiobook.book.title, - downloadFolderUrl: download.downloadFolderUrl - } - var downloadRes = await AudioDownloader.download(downloadRequestPayload) - if (downloadRes.error) { - var errorMsg = downloadRes.error || 'Unknown error' - console.error('Download error', errorMsg) - this.$toast.update(download.toastId, { content: `Error: ${errorMsg}`, options: { timeout: 5000, type: 'error' } }) - this.$store.commit('downloads/removeDownload', download) - } - }, + // console.log('Starting Download URL', url) + // var downloadRequestPayload = { + // audiobookId: download.id, + // filename: download.filename, + // coverFilename, + // coverDownloadUrl, + // downloadUrl: url, + // title: download.audiobook.book.title, + // downloadFolderUrl: download.downloadFolderUrl + // } + // var downloadRes = await AudioDownloader.download(downloadRequestPayload) + // if (downloadRes.error) { + // var errorMsg = downloadRes.error || 'Unknown error' + // console.error('Download error', errorMsg) + // this.$toast.update(download.toastId, { content: `Error: ${errorMsg}`, options: { timeout: 5000, type: 'error' } }) + // this.$store.commit('downloads/removeDownload', download) + // } + // }, downloadReady(prepareDownload) { var download = this.$store.getters['downloads/getDownload'](prepareDownload.audiobookId) if (download) {