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 e91465f2..f45bcf53 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 @@ -99,15 +99,44 @@ class Podcast( episodes?.add(newEpisode) } } + + var index = 1 + episodes?.forEach { + it.index = index + index++ + } } @JsonIgnore override fun addAudioTrack(audioTrack:AudioTrack) { var newEpisode = PodcastEpisode("local_" + audioTrack.localFileId,episodes?.size ?: 0 + 1,null,null,audioTrack.title,null,null,null,audioTrack) episodes?.add(newEpisode) + + var index = 1 + episodes?.forEach { + it.index = index + index++ + } } @JsonIgnore override fun removeAudioTrack(localFileId:String) { episodes?.removeIf { it.audioTrack?.localFileId == localFileId } + + var index = 1 + episodes?.forEach { + it.index = index + index++ + } + } + @JsonIgnore + fun addEpisode(audioTrack:AudioTrack, episode:PodcastEpisode) { + var newEpisode = PodcastEpisode("local_" + episode.id,episodes?.size ?: 0 + 1,episode.episode,episode.episodeType,episode.title,episode.subtitle,episode.description,null,audioTrack) + episodes?.add(newEpisode) + + var index = 1 + episodes?.forEach { + it.index = index + index++ + } } } 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 f056d32c..1ce1e100 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 @@ -20,6 +20,10 @@ class FolderScanner(var ctx: Context) { return "local_" + DeviceManager.getBase64Id(mediaItemId) } + enum class ItemScanResult { + ADDED, REMOVED, UPDATED, UPTODATE + } + // TODO: CLEAN this monster! Divide into bite-size methods fun scanForMediaItems(localFolder:LocalFolder, forceAudioProbe:Boolean):FolderScanResult? { FFmpegKitConfig.enableLogCallback { log -> @@ -57,146 +61,21 @@ class FolderScanner(var ctx: Context) { fileFound != null } - var localLibraryItems = mutableListOf() - foldersFound.forEach { itemFolder -> Log.d(tag, "Iterating over Folder Found ${itemFolder.name} | ${itemFolder.getSimplePath(ctx)} | URI: ${itemFolder.uri}") + var existingItem = existingLocalLibraryItems.find { emi -> emi.id == getLocalLibraryItemId(itemFolder.id) } - var itemFolderName = itemFolder.name ?: "" - var itemId = getLocalLibraryItemId(itemFolder.id) - var itemContentUrl = itemFolder.uri.toString() + var result = scanLibraryItemFolder(itemFolder, localFolder, existingItem, forceAudioProbe) - var existingItem = existingLocalLibraryItems.find { emi -> emi.id == itemId } - var existingLocalFiles = existingItem?.localFiles ?: mutableListOf() - var existingAudioTracks = existingItem?.media?.getAudioTracks() ?: mutableListOf() - var isNewOrUpdated = existingItem == null - - var audioTracks = mutableListOf() - var localFiles = mutableListOf() - var index = 1 - var startOffset = 0.0 - var coverContentUrl:String? = null - var coverAbsolutePath:String? = null - - var filesInFolder = itemFolder.search(false, DocumentFileType.FILE, arrayOf("audio/*", "image/*")) - - - var existingLocalFilesRemoved = existingLocalFiles.filter { elf -> - filesInFolder.find { fif -> DeviceManager.getBase64Id(fif.id) == elf.id } == null // File was not found in media item folder - } - if (existingLocalFilesRemoved.isNotEmpty()) { - Log.d(tag, "${existingLocalFilesRemoved.size} Local files were removed from local media item ${existingItem?.media?.metadata?.title}") - isNewOrUpdated = true - } - - filesInFolder.forEach { file -> - var mimeType = file?.mimeType ?: "" - var filename = file?.name ?: "" - var isAudio = mimeType.startsWith("audio") - Log.d(tag, "Found $mimeType file $filename in folder $itemFolderName") - - var localFileId = DeviceManager.getBase64Id(file.id) - - var localFile = LocalFile(localFileId,filename,file.uri.toString(),file.getBasePath(ctx), file.getAbsolutePath(ctx),file.getSimplePath(ctx),mimeType,file.length()) - localFiles.add(localFile) - - Log.d(tag, "File attributes Id:${localFileId}|ContentUrl:${localFile.contentUrl}|isDownloadsDocument:${file.isDownloadsDocument}") - - if (isAudio) { - var audioTrackToAdd:AudioTrack? = null - - var existingAudioTrack = existingAudioTracks.find { eat -> eat.localFileId == localFileId } - if (existingAudioTrack != null) { // Update existing audio track - if (existingAudioTrack.index != index) { - Log.d(tag, "Updating Audio track index from ${existingAudioTrack.index} to $index") - existingAudioTrack.index = index - isNewOrUpdated = true - } - if (existingAudioTrack.startOffset != startOffset) { - Log.d(tag, "Updating Audio track startOffset ${existingAudioTrack.startOffset} to $startOffset") - existingAudioTrack.startOffset = startOffset - isNewOrUpdated = true - } - } - - if (existingAudioTrack == null || forceAudioProbe) { - Log.d(tag, "Scanning Audio File Path ${localFile.absolutePath}") - - // TODO: Make asynchronous - var session = FFprobeKit.execute("-i \"${localFile.absolutePath}\" -print_format json -show_format -show_streams -select_streams a -show_chapters -loglevel quiet") - - val audioProbeResult = jacksonObjectMapper().readValue(session.output) - Log.d(tag, "Probe Result DATA ${audioProbeResult.duration} | ${audioProbeResult.size} | ${audioProbeResult.title} | ${audioProbeResult.artist}") - - if (existingAudioTrack != null) { - // Update audio probe data on existing audio track - existingAudioTrack.audioProbeResult = audioProbeResult - audioTrackToAdd = existingAudioTrack - } else { - // Create new audio track - var track = AudioTrack(index, startOffset, audioProbeResult.duration, filename, localFile.contentUrl, mimeType, null, true, localFileId, audioProbeResult, null) - audioTrackToAdd = track - } - - startOffset += audioProbeResult.duration - isNewOrUpdated = true - } else { - audioTrackToAdd = existingAudioTrack - } - - startOffset += audioTrackToAdd.duration - index++ - audioTracks.add(audioTrackToAdd) - } else { - var existingLocalFile = existingLocalFiles.find { elf -> elf.id == localFileId } - - if (existingLocalFile == null) { - isNewOrUpdated = true - } - if (existingItem != null && existingItem.coverContentUrl == null) { - // Existing media item did not have a cover - cover found on scan - isNewOrUpdated = true - existingItem.coverAbsolutePath = localFile.absolutePath - existingItem.coverContentUrl = localFile.contentUrl - existingItem.media.coverPath = localFile.absolutePath - } - - // First image file use as cover path - if (coverContentUrl == null) { - coverContentUrl = localFile.contentUrl - coverAbsolutePath = localFile.absolutePath - } - } - } - - if (existingItem != null && audioTracks.isEmpty()) { - Log.d(tag, "Local library item ${existingItem.media.metadata.title} no longer has audio tracks - removing item") - DeviceManager.dbManager.removeLocalLibraryItem(existingItem.id) - mediaItemsRemoved++ - } else if (existingItem != null && !isNewOrUpdated) { - Log.d(tag, "Local library item ${existingItem.media.metadata.title} has no updates") - mediaItemsUpToDate++ - } else if (existingItem != null) { - Log.d(tag, "Updating local library item ${existingItem.media.metadata.title}") - mediaItemsUpdated++ - - existingItem.updateFromScan(audioTracks,localFiles) - localLibraryItems.add(existingItem) - } else if (audioTracks.isNotEmpty()) { - Log.d(tag, "Found local media item named $itemFolderName with ${audioTracks.size} tracks and ${localFiles.size} local files") - mediaItemsAdded++ - - var localMediaItem = LocalMediaItem(itemId, itemFolderName, localFolder.mediaType, localFolder.id, itemFolder.uri.toString(), itemFolder.getSimplePath(ctx), itemFolder.getBasePath(ctx), itemFolder.getAbsolutePath(ctx),audioTracks,localFiles,coverContentUrl,coverAbsolutePath) - var localLibraryItem = localMediaItem.getLocalLibraryItem() - localLibraryItems.add(localLibraryItem) - } + if (result == ItemScanResult.REMOVED) mediaItemsRemoved++ + else if (result == ItemScanResult.UPDATED) mediaItemsUpdated++ + else if (result == ItemScanResult.ADDED) mediaItemsAdded++ + else mediaItemsUpToDate++ } Log.d(tag, "Folder $${localFolder.name} scan Results: $mediaItemsAdded Added | $mediaItemsUpdated Updated | $mediaItemsRemoved Removed | $mediaItemsUpToDate Up-to-date") - return if (localLibraryItems.isNotEmpty()) { - DeviceManager.dbManager.saveLocalLibraryItems(localLibraryItems) - + return if (mediaItemsAdded > 0 || mediaItemsUpdated > 0 || mediaItemsRemoved > 0) { var folderLibraryItems = DeviceManager.dbManager.getLocalLibraryItemsInFolder(localFolder.id) // Get all local media items FolderScanResult(mediaItemsAdded, mediaItemsUpdated, mediaItemsRemoved, mediaItemsUpToDate, localFolder, folderLibraryItems) } else { @@ -205,6 +84,135 @@ class FolderScanner(var ctx: Context) { } } + fun scanLibraryItemFolder(itemFolder:DocumentFile, localFolder:LocalFolder, existingItem:LocalLibraryItem?, forceAudioProbe:Boolean):ItemScanResult { + var itemFolderName = itemFolder.name ?: "" + var itemId = getLocalLibraryItemId(itemFolder.id) + + var existingLocalFiles = existingItem?.localFiles ?: mutableListOf() + var existingAudioTracks = existingItem?.media?.getAudioTracks() ?: mutableListOf() + var isNewOrUpdated = existingItem == null + + var audioTracks = mutableListOf() + var localFiles = mutableListOf() + var index = 1 + var startOffset = 0.0 + var coverContentUrl:String? = null + var coverAbsolutePath:String? = null + + var filesInFolder = itemFolder.search(false, DocumentFileType.FILE, arrayOf("audio/*", "image/*")) + var isPodcast = localFolder.mediaType == "podcast" + + var existingLocalFilesRemoved = existingLocalFiles.filter { elf -> + filesInFolder.find { fif -> DeviceManager.getBase64Id(fif.id) == elf.id } == null // File was not found in media item folder + } + if (existingLocalFilesRemoved.isNotEmpty()) { + Log.d(tag, "${existingLocalFilesRemoved.size} Local files were removed from local media item ${existingItem?.media?.metadata?.title}") + isNewOrUpdated = true + } + + filesInFolder.forEach { file -> + var mimeType = file?.mimeType ?: "" + var filename = file?.name ?: "" + var isAudio = mimeType.startsWith("audio") + Log.d(tag, "Found $mimeType file $filename in folder $itemFolderName") + + var localFileId = DeviceManager.getBase64Id(file.id) + + var localFile = LocalFile(localFileId,filename,file.uri.toString(),file.getBasePath(ctx), file.getAbsolutePath(ctx),file.getSimplePath(ctx),mimeType,file.length()) + localFiles.add(localFile) + + Log.d(tag, "File attributes Id:${localFileId}|ContentUrl:${localFile.contentUrl}|isDownloadsDocument:${file.isDownloadsDocument}") + + if (isAudio) { + var audioTrackToAdd:AudioTrack? = null + + var existingAudioTrack = existingAudioTracks.find { eat -> eat.localFileId == localFileId } + if (existingAudioTrack != null) { // Update existing audio track + if (existingAudioTrack.index != index) { + Log.d(tag, "Updating Audio track index from ${existingAudioTrack.index} to $index") + existingAudioTrack.index = index + isNewOrUpdated = true + } + if (existingAudioTrack.startOffset != startOffset) { + Log.d(tag, "Updating Audio track startOffset ${existingAudioTrack.startOffset} to $startOffset") + existingAudioTrack.startOffset = startOffset + isNewOrUpdated = true + } + } + + if (existingAudioTrack == null || forceAudioProbe) { + Log.d(tag, "Scanning Audio File Path ${localFile.absolutePath}") + + // TODO: Make asynchronous + var session = FFprobeKit.execute("-i \"${localFile.absolutePath}\" -print_format json -show_format -show_streams -select_streams a -show_chapters -loglevel quiet") + + val audioProbeResult = jacksonObjectMapper().readValue(session.output) + Log.d(tag, "Probe Result DATA ${audioProbeResult.duration} | ${audioProbeResult.size} | ${audioProbeResult.title} | ${audioProbeResult.artist}") + + if (existingAudioTrack != null) { + // Update audio probe data on existing audio track + existingAudioTrack.audioProbeResult = audioProbeResult + audioTrackToAdd = existingAudioTrack + } else { + // Create new audio track + var track = AudioTrack(index, startOffset, audioProbeResult.duration, filename, localFile.contentUrl, mimeType, null, true, localFileId, audioProbeResult, null) + audioTrackToAdd = track + } + + startOffset += audioProbeResult.duration + isNewOrUpdated = true + } else { + audioTrackToAdd = existingAudioTrack + } + + startOffset += audioTrackToAdd.duration + index++ + audioTracks.add(audioTrackToAdd) + } else { + var existingLocalFile = existingLocalFiles.find { elf -> elf.id == localFileId } + + if (existingLocalFile == null) { + isNewOrUpdated = true + } + if (existingItem != null && existingItem.coverContentUrl == null) { + // Existing media item did not have a cover - cover found on scan + isNewOrUpdated = true + existingItem.coverAbsolutePath = localFile.absolutePath + existingItem.coverContentUrl = localFile.contentUrl + existingItem.media.coverPath = localFile.absolutePath + } + + // First image file use as cover path + if (coverContentUrl == null) { + coverContentUrl = localFile.contentUrl + coverAbsolutePath = localFile.absolutePath + } + } + } + + if (existingItem != null && audioTracks.isEmpty()) { + Log.d(tag, "Local library item ${existingItem.media.metadata.title} no longer has audio tracks - removing item") + DeviceManager.dbManager.removeLocalLibraryItem(existingItem.id) + return ItemScanResult.REMOVED + } else if (existingItem != null && !isNewOrUpdated) { + Log.d(tag, "Local library item ${existingItem.media.metadata.title} has no updates") + return ItemScanResult.UPTODATE + } else if (existingItem != null) { + Log.d(tag, "Updating local library item ${existingItem.media.metadata.title}") + existingItem.updateFromScan(audioTracks,localFiles) + DeviceManager.dbManager.saveLocalLibraryItem(existingItem) + return ItemScanResult.UPDATED + } else if (audioTracks.isNotEmpty()) { + 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, itemFolder.uri.toString(), itemFolder.getSimplePath(ctx), itemFolder.getBasePath(ctx), itemFolder.getAbsolutePath(ctx),audioTracks,localFiles,coverContentUrl,coverAbsolutePath) + var localLibraryItem = localMediaItem.getLocalLibraryItem() + DeviceManager.dbManager.saveLocalLibraryItem(localLibraryItem) + return ItemScanResult.ADDED + } else { + return ItemScanResult.UPTODATE + } + } + // Scan item after download and create local library item fun scanDownloadItem(downloadItem: AbsDownloader.DownloadItem):LocalLibraryItem? { var folderDf = DocumentFileCompat.fromUri(ctx, Uri.parse(downloadItem.localFolder.contentUrl)) @@ -236,63 +244,82 @@ class FolderScanner(var ctx: Context) { var filesFound = df.search(false, DocumentFileType.FILE, arrayOf("audio/*", "image/*")) Log.d(tag, "scanDownloadItem ${filesFound.size} files found in ${downloadItem.itemFolderPath}") - var localLibraryItem = LocalLibraryItem("local_${downloadItem.id}", downloadItem.localFolder.id, itemFolderBasePath, itemFolderAbsolutePath, itemFolderUrl, false, downloadItem.mediaType, downloadItem.media, mutableListOf(), null, null, true,downloadItem.serverConnectionConfigId,downloadItem.serverAddress,downloadItem.serverUserId,downloadItem.id) - - var localFiles:MutableList = mutableListOf() - var audioTracks:MutableList = mutableListOf() - - filesFound.forEach { docFile -> - var itemPart = downloadItem.downloadItemParts.find { itemPart -> - itemPart.filename == docFile.name - } - if (itemPart == null) { - 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 - var audioTrackFromServer = itemPart.audioTrack - - var localFileId = DeviceManager.getBase64Id(docFile.id) - var localFile = LocalFile(localFileId,docFile.name,docFile.uri.toString(),docFile.getBasePath(ctx),docFile.getAbsolutePath(ctx),docFile.getSimplePath(ctx),docFile.mimeType,docFile.length()) - localFiles.add(localFile) - - // TODO: Make asynchronous - var session = FFprobeKit.execute("-i \"${localFile.absolutePath}\" -print_format json -show_format -show_streams -select_streams a -show_chapters -loglevel quiet") - - val audioProbeResult = jacksonObjectMapper().readValue(session.output) - Log.d(tag, "Probe Result DATA ${audioProbeResult.duration} | ${audioProbeResult.size} | ${audioProbeResult.title} | ${audioProbeResult.artist}") - - // Create new audio track - var track = AudioTrack(audioTrackFromServer?.index ?: -1, audioTrackFromServer?.startOffset ?: 0.0, audioProbeResult.duration, localFile.filename ?: "", localFile.contentUrl, localFile.mimeType ?: "", null, true, localFileId, audioProbeResult, audioTrackFromServer?.index ?: -1) - audioTracks.add(track) - } else { // Cover image - var localFileId = DeviceManager.getBase64Id(docFile.id) - var localFile = LocalFile(localFileId,docFile.name,docFile.uri.toString(),docFile.getBasePath(ctx),docFile.getAbsolutePath(ctx),docFile.getSimplePath(ctx),docFile.mimeType,docFile.length()) - localFiles.add(localFile) - - localLibraryItem.coverAbsolutePath = localFile.absolutePath - localLibraryItem.coverContentUrl = localFile.contentUrl + var localLibraryItem:LocalLibraryItem? = null + if (downloadItem.mediaType == "book") { + localLibraryItem = LocalLibraryItem("local_${downloadItem.libraryItemId}", downloadItem.localFolder.id, itemFolderBasePath, itemFolderAbsolutePath, itemFolderUrl, false, downloadItem.mediaType, downloadItem.media, mutableListOf(), null, null, true, downloadItem.serverConnectionConfigId, downloadItem.serverAddress, downloadItem.serverUserId, downloadItem.libraryItemId) + } else { + // Lookup or create podcast local library item + localLibraryItem = DeviceManager.dbManager.getLocalLibraryItem("local_${downloadItem.libraryItemId}") + if (localLibraryItem == null) { + Log.d(tag, "Podcast local library item not created yet for ${downloadItem.media.metadata.title}") + localLibraryItem = LocalLibraryItem("local_${downloadItem.libraryItemId}", downloadItem.localFolder.id, itemFolderBasePath, itemFolderAbsolutePath, itemFolderUrl, false, downloadItem.mediaType, downloadItem.media, mutableListOf(), null, null, true,downloadItem.serverConnectionConfigId,downloadItem.serverAddress,downloadItem.serverUserId,downloadItem.libraryItemId) } } + var audioTracks:MutableList = mutableListOf() + + filesFound.forEach { docFile -> + var 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 + var audioTrackFromServer = itemPart.audioTrack + + var localFileId = DeviceManager.getBase64Id(docFile.id) + var 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) + + // TODO: Make asynchronous + var session = FFprobeKit.execute("-i \"${localFile.absolutePath}\" -print_format json -show_format -show_streams -select_streams a -show_chapters -loglevel quiet") + + val audioProbeResult = jacksonObjectMapper().readValue(session.output) + Log.d(tag, "Probe Result DATA ${audioProbeResult.duration} | ${audioProbeResult.size} | ${audioProbeResult.title} | ${audioProbeResult.artist}") + + // Create new audio track + var track = AudioTrack(audioTrackFromServer?.index ?: -1, audioTrackFromServer?.startOffset ?: 0.0, audioProbeResult.duration, localFile.filename ?: "", localFile.contentUrl, localFile.mimeType ?: "", null, true, localFileId, audioProbeResult, audioTrackFromServer?.index ?: -1) + audioTracks.add(track) + + // Add podcast episodes to library + itemPart.episode?.let { podcastEpisode -> + var podcast = localLibraryItem.media as Podcast + podcast.addEpisode(track, podcastEpisode) + } + } else { // Cover image + var localFileId = DeviceManager.getBase64Id(docFile.id) + var 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()) { Log.d(tag, "scanDownloadItem did not find any audio tracks in folder for ${downloadItem.itemFolderPath}") return null } - audioTracks.sortBy { it.index } + // 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 + 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 } - indexCheck++ - startOffset += audioTrack.duration - } - localLibraryItem.media.setAudioTracks(audioTracks) - localLibraryItem.localFiles = localFiles + localLibraryItem.media.setAudioTracks(audioTracks) + } DeviceManager.dbManager.saveLocalLibraryItem(localLibraryItem) diff --git a/android/app/src/main/java/com/audiobookshelf/app/plugins/AbsAudioPlayer.kt b/android/app/src/main/java/com/audiobookshelf/app/plugins/AbsAudioPlayer.kt index 43061f4b..e27e3914 100644 --- a/android/app/src/main/java/com/audiobookshelf/app/plugins/AbsAudioPlayer.kt +++ b/android/app/src/main/java/com/audiobookshelf/app/plugins/AbsAudioPlayer.kt @@ -12,6 +12,7 @@ import com.audiobookshelf.app.device.DeviceManager import com.audiobookshelf.app.player.CastManager import com.audiobookshelf.app.player.PlayerNotificationService import com.audiobookshelf.app.server.ApiHandler +import com.fasterxml.jackson.core.json.JsonReadFeature import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper import com.getcapacitor.* import com.getcapacitor.annotation.CapacitorPlugin 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 e7899183..75c56558 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 @@ -43,6 +43,7 @@ class AbsDownloader : Plugin() { val localFolderName: String, val localFolderId: String, val audioTrack: AudioTrack?, + val episode:PodcastEpisode?, var completed:Boolean, @JsonIgnore val uri: Uri, @JsonIgnore val destinationUri: Uri, @@ -62,6 +63,8 @@ class AbsDownloader : Plugin() { data class DownloadItem( val id: String, + val libraryItemId:String, + val episodeId:String?, val serverConnectionConfigId:String, val serverAddress:String, val serverUserId:String, @@ -96,20 +99,40 @@ class AbsDownloader : Plugin() { @PluginMethod fun downloadLibraryItem(call: PluginCall) { var libraryItemId = call.data.getString("libraryItemId").toString() + var episodeId = call.data.getString("episodeId").toString() var localFolderId = call.data.getString("localFolderId").toString() Log.d(tag, "Download library item $libraryItemId to folder $localFolderId") - if (downloadQueue.find { it.id == libraryItemId } != null) { - Log.d(tag, "Download already started for this library item $libraryItemId") - return call.resolve(JSObject("{\"error\":\"Download already started for this library item\"}")) + var downloadId = if (episodeId.isNullOrEmpty()) libraryItemId else "$libraryItemId-$episodeId" + if (downloadQueue.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\"}")) } 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() + + if (!episodeId.isNullOrEmpty() && libraryItem.mediaType != "podcast") { + Log.e(tag, "Library item is not a podcast but episode was requested") + call.resolve(JSObject("{\"error\":\"Invalid library item not a podcast\"}")) + } else if (!episodeId.isNullOrEmpty()) { + var podcast = libraryItem.media as Podcast + var episode = podcast.episodes?.find { podcastEpisode -> + podcastEpisode.id == episodeId + } + if (episode == null) { + call.resolve(JSObject("{\"error\":\"Invalid podcast episode not found\"}")) + } else { + startLibraryItemDownload(libraryItem, localFolder, episode) + call.resolve() + } + } else { + startLibraryItemDownload(libraryItem, localFolder, null) + call.resolve() + } } else { call.resolve(JSObject("{\"error\":\"Local Folder Not Found\"}")) } @@ -139,13 +162,13 @@ class AbsDownloader : Plugin() { return fileString } - fun startLibraryItemDownload(libraryItem: LibraryItem, localFolder: LocalFolder) { + fun startLibraryItemDownload(libraryItem: LibraryItem, localFolder: LocalFolder, episode:PodcastEpisode?) { if (libraryItem.mediaType == "book") { var bookTitle = libraryItem.media.metadata.title var tracks = libraryItem.media.getAudioTracks() Log.d(tag, "Starting library item download with ${tracks.size} tracks") var itemFolderPath = localFolder.absolutePath + "/" + bookTitle - var downloadItem = DownloadItem(libraryItem.id, DeviceManager.serverConnectionConfig?.id ?: "", DeviceManager.serverAddress, DeviceManager.serverUserId, libraryItem.mediaType, itemFolderPath, localFolder, bookTitle, libraryItem.media, mutableListOf()) + var downloadItem = DownloadItem(libraryItem.id, libraryItem.id, null,DeviceManager.serverConnectionConfig?.id ?: "", DeviceManager.serverAddress, DeviceManager.serverUserId, libraryItem.mediaType, itemFolderPath, localFolder, bookTitle, libraryItem.media, mutableListOf()) // Create download item part for each audio track tracks.forEach { audioTrack -> @@ -162,7 +185,7 @@ class AbsDownloader : Plugin() { var destinationUri = Uri.fromFile(destinationFile) var downloadUri = Uri.parse("${DeviceManager.serverAddress}${serverPath}?token=${DeviceManager.token}") Log.d(tag, "Audio File Destination Uri $destinationUri | Download URI $downloadUri") - var downloadItemPart = DownloadItemPart(DeviceManager.getBase64Id(destinationFile.absolutePath), destinationFilename, destinationFile.absolutePath, bookTitle, serverPath, localFolder.name, localFolder.id, audioTrack, false, downloadUri, destinationUri, null, 0) + var downloadItemPart = DownloadItemPart(DeviceManager.getBase64Id(destinationFile.absolutePath), destinationFilename, destinationFile.absolutePath, bookTitle, serverPath, localFolder.name, localFolder.id, audioTrack, null, false, downloadUri, destinationUri, null, 0) downloadItem.downloadItemParts.add(downloadItemPart) @@ -185,7 +208,7 @@ class AbsDownloader : Plugin() { var destinationUri = Uri.fromFile(destinationFile) var downloadUri = Uri.parse("${DeviceManager.serverAddress}${serverPath}&token=${DeviceManager.token}") - var downloadItemPart = DownloadItemPart(DeviceManager.getBase64Id(destinationFile.absolutePath), destinationFilename, destinationFile.absolutePath, bookTitle, serverPath, localFolder.name, localFolder.id, null, false, downloadUri, destinationUri, null, 0) + var downloadItemPart = DownloadItemPart(DeviceManager.getBase64Id(destinationFile.absolutePath), destinationFilename, destinationFile.absolutePath, bookTitle, serverPath, localFolder.name, localFolder.id, null,null, false, downloadUri, destinationUri, null, 0) downloadItem.downloadItemParts.add(downloadItemPart) @@ -204,7 +227,57 @@ class AbsDownloader : Plugin() { DeviceManager.dbManager.saveDownloadItem(downloadItem) } } else { - // TODO: Download podcast episode(s) + // Podcast episode download + + var podcastTitle = libraryItem.media.metadata.title + var audioTrack = episode?.audioTrack + Log.d(tag, "Starting podcast episode download") + var itemFolderPath = localFolder.absolutePath + "/" + podcastTitle + var downloadItemId = "${libraryItem.id}-${episode?.id}" + var downloadItem = DownloadItem(downloadItemId, libraryItem.id, episode?.id, DeviceManager.serverConnectionConfig?.id ?: "", DeviceManager.serverAddress, DeviceManager.serverUserId, libraryItem.mediaType, itemFolderPath, localFolder, podcastTitle, libraryItem.media, mutableListOf()) + + var serverPath = "/s/item/${libraryItem.id}/${cleanRelPath(audioTrack?.relPath ?: "")}" + var destinationFilename = getFilenameFromRelPath(audioTrack?.relPath ?: "") + Log.d(tag, "Audio File Server Path $serverPath | AF RelPath ${audioTrack?.relPath} | LocalFolder Path ${localFolder.absolutePath} | DestName ${destinationFilename}") + var destinationFile = File("$itemFolderPath/$destinationFilename") + if (destinationFile.exists()) { + Log.d(tag, "Audio file already exists, removing it from ${destinationFile.absolutePath}") + destinationFile.delete() + } + + var destinationUri = Uri.fromFile(destinationFile) + var downloadUri = Uri.parse("${DeviceManager.serverAddress}${serverPath}?token=${DeviceManager.token}") + Log.d(tag, "Audio File Destination Uri $destinationUri | Download URI $downloadUri") + var downloadItemPart = DownloadItemPart(DeviceManager.getBase64Id(destinationFile.absolutePath), destinationFilename, destinationFile.absolutePath, podcastTitle, serverPath, localFolder.name, localFolder.id, audioTrack, episode,false, downloadUri, destinationUri, null, 0) + 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) { + var serverPath = "/api/items/${libraryItem.id}/cover?format=jpeg" + var destinationFilename = "cover.jpg" + var destinationFile = File("$itemFolderPath/$destinationFilename") + + if (destinationFile.exists()) { + Log.d(tag, "Podcast cover already exists - not downloading cover again") + } else { + var destinationUri = Uri.fromFile(destinationFile) + var downloadUri = Uri.parse("${DeviceManager.serverAddress}${serverPath}&token=${DeviceManager.token}") + var downloadItemPart = DownloadItemPart(DeviceManager.getBase64Id(destinationFile.absolutePath), destinationFilename, destinationFile.absolutePath, podcastTitle, serverPath, localFolder.name, localFolder.id, null,null, false, downloadUri, destinationUri, null, 0) + + downloadItem.downloadItemParts.add(downloadItemPart) + + var dlRequest = downloadItemPart.getDownloadRequest() + var downloadId = downloadManager.enqueue(dlRequest) + downloadItemPart.downloadId = downloadId + } + } + + downloadQueue.add(downloadItem) + startWatchingDownloads(downloadItem) + DeviceManager.dbManager.saveDownloadItem(downloadItem) } } diff --git a/components/app/AudioPlayer.vue b/components/app/AudioPlayer.vue index 694c6693..13d001af 100644 --- a/components/app/AudioPlayer.vue +++ b/components/app/AudioPlayer.vue @@ -105,11 +105,9 @@ export default { }, data() { return { - // Main playbackSession: null, - // Others showChapterModal: false, - showCastBtn: true, + showCastBtn: false, showFullscreen: false, totalDuration: 0, currentPlaybackRate: 1, @@ -493,6 +491,7 @@ export default { onPlayingUpdate(data) { console.log('onPlayingUpdate', JSON.stringify(data)) this.isPaused = !data.value + this.$store.commit('setPlayerPlaying', !this.isPaused) if (!this.isPaused) { this.startPlayInterval() } else { @@ -519,6 +518,8 @@ export default { console.log('onPlaybackSession received', JSON.stringify(playbackSession)) this.playbackSession = playbackSession + this.$store.commit('setPlayerItem', this.playbackSession) + // Set track width this.$nextTick(() => { if (this.$refs.track) { @@ -530,6 +531,7 @@ export default { }, onPlaybackClosed() { console.log('Received onPlaybackClosed evt') + this.$store.commit('setPlayerItem', null) this.showFullscreen = false this.playbackSession = null }, diff --git a/components/app/AudioPlayerContainer.vue b/components/app/AudioPlayerContainer.vue index 653c28df..14c96824 100644 --- a/components/app/AudioPlayerContainer.vue +++ b/components/app/AudioPlayerContainer.vue @@ -166,9 +166,12 @@ export default { this.$refs.audioPlayer.terminateStream() } }, - async playLibraryItem(libraryItemId) { + async playLibraryItem(payload) { + var libraryItemId = payload.libraryItemId + var episodeId = payload.episodeId + console.log('Called playLibraryItem', libraryItemId) - AbsAudioPlayer.prepareLibraryItem({ libraryItemId, playWhenReady: true }) + AbsAudioPlayer.prepareLibraryItem({ libraryItemId, episodeId, playWhenReady: true }) .then((data) => { console.log('Library item play response', JSON.stringify(data)) }) @@ -176,6 +179,11 @@ export default { console.error('Failed', error) }) }, + pauseItem() { + if (this.$refs.audioPlayer && !this.$refs.audioPlayer.isPaused) { + this.$refs.audioPlayer.pause() + } + }, onLocalMediaProgressUpdate(localMediaProgress) { console.log('Got local media progress update', localMediaProgress.progress, JSON.stringify(localMediaProgress)) this.$store.commit('globals/updateLocalMediaProgress', localMediaProgress) @@ -191,6 +199,7 @@ export default { this.setListeners() this.$eventBus.$on('play-item', this.playLibraryItem) + this.$eventBus.$on('pause-item', this.pauseItem) this.$eventBus.$on('close-stream', this.closeStreamOnly) this.$store.commit('user/addSettingsListener', { id: 'streamContainer', meth: this.settingsUpdated }) }, @@ -207,6 +216,7 @@ export default { // this.$server.socket.off('stream_reset', this.streamReset) // } this.$eventBus.$off('play-item', this.playLibraryItem) + this.$eventBus.$off('pause-item', this.pauseItem) this.$eventBus.$off('close-stream', this.closeStreamOnly) this.$store.commit('user/removeSettingsListener', 'streamContainer') } diff --git a/components/cards/LazyBookCard.vue b/components/cards/LazyBookCard.vue index 2f8d98bf..d5d8310b 100644 --- a/components/cards/LazyBookCard.vue +++ b/components/cards/LazyBookCard.vue @@ -424,7 +424,7 @@ export default { }, play() { var eventBus = this.$eventBus || this.$nuxt.$eventBus - eventBus.$emit('play-item', this.libraryItemId) + eventBus.$emit('play-item', { libraryItemId: this.libraryItemId }) }, destroy() { // destroy the vue listeners, etc diff --git a/components/cards/LazyListBookCard.vue b/components/cards/LazyListBookCard.vue index c9ad30d9..6ffbc7ec 100644 --- a/components/cards/LazyListBookCard.vue +++ b/components/cards/LazyListBookCard.vue @@ -397,7 +397,7 @@ export default { }, play() { var eventBus = this.$eventBus || this.$nuxt.$eventBus - eventBus.$emit('play-item', this.libraryItemId) + eventBus.$emit('play-item', { libraryItemId: this.libraryItemId }) }, destroy() { // destroy the vue listeners, etc diff --git a/components/connection/ServerConnectForm.vue b/components/connection/ServerConnectForm.vue index ea61f8c8..224e3406 100644 --- a/components/connection/ServerConnectForm.vue +++ b/components/connection/ServerConnectForm.vue @@ -261,24 +261,28 @@ export default { } }, async setUserAndConnection(user, userDefaultLibraryId) { - if (user) { - console.log('Successfully logged in', JSON.stringify(user)) + if (!user) return - if (userDefaultLibraryId) { - this.$store.commit('libraries/setCurrentLibrary', userDefaultLibraryId) - } + console.log('Successfully logged in', JSON.stringify(user)) - this.serverConfig.userId = user.id - this.serverConfig.token = user.token - - var serverConnectionConfig = await this.$db.setServerConnectionConfig(this.serverConfig) - - this.$store.commit('user/setUser', user) - this.$store.commit('user/setServerConnectionConfig', serverConnectionConfig) - - this.$socket.connect(this.serverConfig.address, this.serverConfig.token) - this.$router.replace('/bookshelf') + // Set library - Use last library if set and available fallback to default user library + var lastLibraryId = await this.$localStore.getLastLibraryId() + if (lastLibraryId && (!user.librariesAccessible.length || user.librariesAccessible.includes(lastLibraryId))) { + this.$store.commit('libraries/setCurrentLibrary', lastLibraryId) + } else if (userDefaultLibraryId) { + this.$store.commit('libraries/setCurrentLibrary', userDefaultLibraryId) } + + this.serverConfig.userId = user.id + this.serverConfig.token = user.token + + var serverConnectionConfig = await this.$db.setServerConnectionConfig(this.serverConfig) + + this.$store.commit('user/setUser', user) + this.$store.commit('user/setServerConnectionConfig', serverConnectionConfig) + + this.$socket.connect(this.serverConfig.address, this.serverConfig.token) + this.$router.replace('/bookshelf') }, async authenticateToken() { if (!this.networkConnected) return diff --git a/components/modals/LibrariesModal.vue b/components/modals/LibrariesModal.vue index 1175a7b0..83cb6902 100644 --- a/components/modals/LibrariesModal.vue +++ b/components/modals/LibrariesModal.vue @@ -55,6 +55,7 @@ export default { this.show = false await this.$store.dispatch('libraries/fetch', lib.id) this.$eventBus.$emit('library-changed', lib.id) + this.$localStore.setLastLibraryId(lib.id) } }, mounted() {} diff --git a/components/tables/collection/BookTableRow.vue b/components/tables/collection/BookTableRow.vue index 02c3ef96..841d01c6 100644 --- a/components/tables/collection/BookTableRow.vue +++ b/components/tables/collection/BookTableRow.vue @@ -65,7 +65,7 @@ export default { return this.book.numTracks }, isStreaming() { - return this.$store.getters['getAudiobookIdStreaming'] === this.book.id + return this.$store.getters['getIsItemStreaming'](this.book.id) }, showPlayBtn() { return !this.isMissing && !this.isIncomplete && !this.isStreaming && this.numTracks diff --git a/components/tables/podcast/EpisodeRow.vue b/components/tables/podcast/EpisodeRow.vue new file mode 100644 index 00000000..5f43b19c --- /dev/null +++ b/components/tables/podcast/EpisodeRow.vue @@ -0,0 +1,183 @@ + + + \ No newline at end of file diff --git a/components/tables/podcast/EpisodesTable.vue b/components/tables/podcast/EpisodesTable.vue new file mode 100644 index 00000000..590ac4dc --- /dev/null +++ b/components/tables/podcast/EpisodesTable.vue @@ -0,0 +1,25 @@ + + + \ No newline at end of file diff --git a/components/ui/ReadIconBtn.vue b/components/ui/ReadIconBtn.vue new file mode 100644 index 00000000..934b0c6b --- /dev/null +++ b/components/ui/ReadIconBtn.vue @@ -0,0 +1,57 @@ + + + + + \ No newline at end of file diff --git a/components/widgets/DownloadProgressIndicator.vue b/components/widgets/DownloadProgressIndicator.vue index ae48ae94..30c2f0d2 100644 --- a/components/widgets/DownloadProgressIndicator.vue +++ b/components/widgets/DownloadProgressIndicator.vue @@ -62,12 +62,14 @@ export default { var update = { id: data.id, + libraryItemId: data.libraryItemId, partsRemaining, partsCompleted, totalParts: downloadItemParts.length, itemProgress } data.itemProgress = itemProgress + data.episodes = downloadItemParts.filter((dip) => dip.episode).map((dip) => dip.episode) console.log('Saving item update download payload', JSON.stringify(update)) this.$set(this.itemDownloadingMap, update.id, update) diff --git a/layouts/default.vue b/layouts/default.vue index e7c2273d..9376a594 100644 --- a/layouts/default.vue +++ b/layouts/default.vue @@ -13,7 +13,6 @@