diff --git a/android/app/src/main/java/com/audiobookshelf/app/CastOptionsProvider.kt b/android/app/src/main/java/com/audiobookshelf/app/CastOptionsProvider.kt index b48d17de..9f4d1442 100644 --- a/android/app/src/main/java/com/audiobookshelf/app/CastOptionsProvider.kt +++ b/android/app/src/main/java/com/audiobookshelf/app/CastOptionsProvider.kt @@ -12,8 +12,9 @@ class CastOptionsProvider : OptionsProvider { override fun getCastOptions(context: Context): CastOptions { Log.d("CastOptionsProvider", "getCastOptions") var appId = "FD1F76C5" + var defaultId =CastMediaControlIntent.DEFAULT_MEDIA_RECEIVER_APPLICATION_ID return CastOptions.Builder() - .setReceiverApplicationId(CastMediaControlIntent.DEFAULT_MEDIA_RECEIVER_APPLICATION_ID).setCastMediaOptions( + .setReceiverApplicationId(appId).setCastMediaOptions( CastMediaOptions.Builder() // We manage the media session and the notifications ourselves. .setMediaSessionEnabled(false) diff --git a/android/app/src/main/java/com/audiobookshelf/app/data/AudioProbeResult.kt b/android/app/src/main/java/com/audiobookshelf/app/data/AudioProbeResult.kt index 5187a349..da40ef16 100644 --- a/android/app/src/main/java/com/audiobookshelf/app/data/AudioProbeResult.kt +++ b/android/app/src/main/java/com/audiobookshelf/app/data/AudioProbeResult.kt @@ -2,6 +2,11 @@ package com.audiobookshelf.app.data import com.fasterxml.jackson.annotation.JsonIgnore import com.fasterxml.jackson.annotation.JsonIgnoreProperties +import com.fasterxml.jackson.annotation.JsonInclude +import com.fasterxml.jackson.annotation.JsonProperty +import com.fasterxml.jackson.core.json.JsonReadFeature +import com.fasterxml.jackson.databind.annotation.JsonDeserialize +import com.fasterxml.jackson.databind.jsonschema.JsonSerializableSchema @JsonIgnoreProperties(ignoreUnknown = true) data class AudioProbeStream( 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 f45bcf53..12ddc893 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 @@ -71,6 +71,8 @@ open class MediaType(var metadata:MediaTypeMetadata, var coverPath:String?) { open fun addAudioTrack(audioTrack:AudioTrack) { } @JsonIgnore open fun removeAudioTrack(localFileId:String) { } + @JsonIgnore + open fun getLocalCopy():MediaType { return MediaType(MediaTypeMetadata(""),null) } } @JsonIgnoreProperties(ignoreUnknown = true) @@ -92,10 +94,11 @@ class Podcast( episodes = episodes?.filter { ep -> audioTracks.find { it.localFileId == ep.audioTrack?.localFileId } != null } as MutableList + // Add new episodes audioTracks.forEach { at -> if (episodes?.find{ it.audioTrack?.localFileId == at.localFileId } == null) { - var newEpisode = PodcastEpisode("local_" + at.localFileId,episodes?.size ?: 0 + 1,null,null,at.title,null,null,null,at) + var newEpisode = PodcastEpisode("local_" + at.localFileId,episodes?.size ?: 0 + 1,null,null,at.title,null,null,null,at,at.duration,0, null) episodes?.add(newEpisode) } } @@ -108,7 +111,7 @@ class Podcast( } @JsonIgnore override fun addAudioTrack(audioTrack:AudioTrack) { - var newEpisode = PodcastEpisode("local_" + audioTrack.localFileId,episodes?.size ?: 0 + 1,null,null,audioTrack.title,null,null,null,audioTrack) + var newEpisode = PodcastEpisode("local_" + audioTrack.localFileId,episodes?.size ?: 0 + 1,null,null,audioTrack.title,null,null,null,audioTrack,audioTrack.duration,0, null) episodes?.add(newEpisode) var index = 1 @@ -129,7 +132,7 @@ class Podcast( } @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) + var newEpisode = PodcastEpisode("local_" + episode.id,episodes?.size ?: 0 + 1,episode.episode,episode.episodeType,episode.title,episode.subtitle,episode.description,null,audioTrack,audioTrack.duration,0, episode.id) episodes?.add(newEpisode) var index = 1 @@ -138,6 +141,12 @@ class Podcast( index++ } } + + // Used for FolderScanner local podcast item to get copy of Podcast excluding episodes + @JsonIgnore + override fun getLocalCopy(): Podcast { + return Podcast(metadata as PodcastMetadata,coverPath,tags, mutableListOf(),autoDownloadEpisodes) + } } @JsonIgnoreProperties(ignoreUnknown = true) @@ -158,7 +167,7 @@ class Book( @JsonIgnore override fun setAudioTracks(audioTracks:MutableList) { tracks = audioTracks - + tracks?.sortBy { it.index } // TODO: Is it necessary to calculate this each time? check if can remove safely var totalDuration = 0.0 tracks?.forEach { @@ -195,6 +204,11 @@ class Book( } duration = totalDuration } + + @JsonIgnore + override fun getLocalCopy(): Book { + return Book(metadata as BookMetadata,coverPath,tags, mutableListOf(),chapters,mutableListOf(),null,null) + } } // This auto-detects whether it is a BookMetadata or PodcastMetadata @@ -261,7 +275,10 @@ data class PodcastEpisode( var subtitle:String?, var description:String?, var audioFile:AudioFile?, - var audioTrack:AudioTrack? + var audioTrack:AudioTrack?, + var duration:Double?, + var size:Long?, + var serverEpisodeId:String? // For local podcasts to match with server podcasts ) @JsonIgnoreProperties(ignoreUnknown = true) @@ -275,7 +292,8 @@ data class FileMetadata( var filename:String, var ext:String, var path:String, - var relPath:String + var relPath:String, + var size:Long? ) @JsonIgnoreProperties(ignoreUnknown = true) 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 cc23d8e3..4f37950e 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 @@ -4,6 +4,7 @@ import android.util.Log import com.audiobookshelf.app.plugins.AbsDownloader import io.paperdb.Paper import org.json.JSONObject +import java.io.File class DbManager { val tag = "DbManager" @@ -130,6 +131,92 @@ class DbManager { Paper.book("localMediaProgress").delete(localMediaProgressId) } + fun removeAllLocalMediaProgress() { + Paper.book("localMediaProgress").destroy() + } + + // Make sure all local file ids still exist + fun cleanLocalLibraryItems() { + var localLibraryItems = getLocalLibraryItems() + + localLibraryItems.forEach { lli -> + var hasUpates = false + + // Check local files + lli.localFiles = lli.localFiles.filter { localFile -> + var file = File(localFile.absolutePath) + if (!file.exists()) { + Log.d(tag, "cleanLocalLibraryItems: Local file ${localFile.absolutePath} was removed from library item ${lli.media.metadata.title}") + hasUpates = true + } + file.exists() + } as MutableList + + // Check audio tracks and episodes + if (lli.isPodcast) { + var podcast = lli.media as Podcast + podcast.episodes = podcast.episodes?.filter { ep -> + if (lli.localFiles.find { lf -> lf.id == ep.audioTrack?.localFileId } == null) { + Log.d(tag, "cleanLocalLibraryItems: Podcast episode ${ep.title} was removed from library item ${lli.media.metadata.title}") + hasUpates = true + } + ep.audioTrack != null && lli.localFiles.find { lf -> lf.id == ep.audioTrack?.localFileId } != null + } as MutableList + } else { + var book = lli.media as Book + book.tracks = book.tracks?.filter { track -> + if (lli.localFiles.find { lf -> lf.id == track.localFileId } == null) { + Log.d(tag, "cleanLocalLibraryItems: Audio track ${track.title} was removed from library item ${lli.media.metadata.title}") + hasUpates = true + } + lli.localFiles.find { lf -> lf.id == track.localFileId } != null + } as MutableList + } + + // Check cover still there + lli.coverAbsolutePath?.let { + var coverFile = File(it) + + if (!coverFile.exists()) { + Log.d(tag, "cleanLocalLibraryItems: Cover $it was removed from library item ${lli.media.metadata.title}") + lli.coverAbsolutePath = null + lli.coverContentUrl = null + hasUpates = true + } + } + + if (hasUpates) { + Log.d(tag, "cleanLocalLibraryItems: Saving local library item ${lli.id}") + Paper.book("localLibraryItems").write(lli.id, lli) + } + } + } + + // Remove any local media progress where the local media item is not found + fun cleanLocalMediaProgress() { + var localMediaProgress = getAllLocalMediaProgress() + var localLibraryItems = getLocalLibraryItems() + localMediaProgress.forEach { + var matchingLLI = localLibraryItems.find { lli -> lli.id == it.localLibraryItemId } + if (matchingLLI == null) { + Log.d(tag, "cleanLocalMediaProgress: No matching local library item for local media progress ${it.id} - removing") + Paper.book("localMediaProgress").delete(it.id) + } else if (matchingLLI.isPodcast) { + if (it.localEpisodeId.isNullOrEmpty()) { + Log.d(tag, "cleanLocalMediaProgress: Podcast media progress has no episode id - removing") + Paper.book("localMediaProgress").delete(it.id) + } else { + var podcast = matchingLLI.media as Podcast + var matchingLEp = podcast.episodes?.find { ep -> ep.id == it.localEpisodeId } + if (matchingLEp == null) { + Log.d(tag, "cleanLocalMediaProgress: Podcast media progress for episode ${it.localEpisodeId} not found - removing") + Paper.book("localMediaProgress").delete(it.id) + } + } + } + } + } + fun saveLocalPlaybackSession(playbackSession:PlaybackSession) { Paper.book("localPlaybackSession").write(playbackSession.id,playbackSession) } diff --git a/android/app/src/main/java/com/audiobookshelf/app/data/LocalLibraryItem.kt b/android/app/src/main/java/com/audiobookshelf/app/data/LocalLibraryItem.kt index 01b8bcc7..8c641ff7 100644 --- a/android/app/src/main/java/com/audiobookshelf/app/data/LocalLibraryItem.kt +++ b/android/app/src/main/java/com/audiobookshelf/app/data/LocalLibraryItem.kt @@ -1,5 +1,6 @@ package com.audiobookshelf.app.data +import android.util.Log import com.audiobookshelf.app.device.DeviceManager import com.fasterxml.jackson.annotation.JsonIgnore import com.fasterxml.jackson.annotation.JsonIgnoreProperties @@ -26,6 +27,9 @@ data class LocalLibraryItem( var libraryItemId:String? ) { + @get:JsonIgnore + val isPodcast get() = mediaType == "podcast" + @JsonIgnore fun getDuration():Double { var total = 0.0 @@ -50,25 +54,26 @@ data class LocalLibraryItem( } @JsonIgnore - fun getPlaybackSession(episodeId:String):PlaybackSession { - var sessionId = "play-${UUID.randomUUID()}" + fun getPlaybackSession(episode:PodcastEpisode?):PlaybackSession { + var localEpisodeId = episode?.id + var sessionId = "play_local_${UUID.randomUUID()}" - val mediaProgressId = if (episodeId.isNullOrEmpty()) id else "$id-$episodeId" + val mediaProgressId = if (localEpisodeId.isNullOrEmpty()) id else "$id-$localEpisodeId" var mediaProgress = DeviceManager.dbManager.getLocalMediaProgress(mediaProgressId) var currentTime = mediaProgress?.currentTime ?: 0.0 // TODO: Clean up add mediaType methods for displayTitle and displayAuthor var mediaMetadata = media.metadata var chapters = if (mediaType == "book") (media as Book).chapters else mutableListOf() - var authorName = "Unknown" - if (mediaType == "book") { - var bookMetadata = mediaMetadata as BookMetadata - authorName = bookMetadata?.authorName ?: "Unknown" + var audioTracks = media.getAudioTracks() as MutableList + var authorName = mediaMetadata.getAuthorDisplayName() + if (episode != null) { // Get podcast episode audio track + episode.audioTrack?.let { at -> mutableListOf(at) }?.let { tracks -> audioTracks = tracks } + Log.d("LocalLibraryItem", "getPlaybackSession: Got podcast episode audio track ${audioTracks.size}") } - var episodeIdNullable = if (episodeId.isNullOrEmpty()) null else episodeId var dateNow = System.currentTimeMillis() - return PlaybackSession(sessionId,serverUserId,libraryItemId,episodeIdNullable, mediaType, mediaMetadata, chapters ?: mutableListOf(), mediaMetadata.title, authorName,null,getDuration(),PLAYMETHOD_LOCAL,dateNow,0L,0L, media.getAudioTracks() as MutableList,currentTime,null,this,serverConnectionConfigId, serverAddress) + return PlaybackSession(sessionId,serverUserId,libraryItemId,episode?.serverEpisodeId, mediaType, mediaMetadata, chapters ?: mutableListOf(), mediaMetadata.title, authorName,null,getDuration(),PLAYMETHOD_LOCAL,dateNow,0L,0L, audioTracks,currentTime,null,this,localEpisodeId,serverConnectionConfigId, serverAddress) } @JsonIgnore diff --git a/android/app/src/main/java/com/audiobookshelf/app/data/LocalMediaItem.kt b/android/app/src/main/java/com/audiobookshelf/app/data/LocalMediaItem.kt index 2a14c847..e7038800 100644 --- a/android/app/src/main/java/com/audiobookshelf/app/data/LocalMediaItem.kt +++ b/android/app/src/main/java/com/audiobookshelf/app/data/LocalMediaItem.kt @@ -65,6 +65,7 @@ data class LocalMediaItem( return LocalLibraryItem(id, folderId, basePath,absolutePath, contentUrl, false,mediaType, book, localFiles, coverContentUrl, coverAbsolutePath,true,null,null,null,null) } else { var podcast = Podcast(mediaMetadata as PodcastMetadata, coverAbsolutePath, mutableListOf(), mutableListOf(), false) + podcast.setAudioTracks(audioTracks) // Builds episodes from audio tracks return LocalLibraryItem(id, folderId, basePath,absolutePath, contentUrl, false, mediaType, podcast,localFiles,coverContentUrl, coverAbsolutePath, true, null,null,null,null) } } diff --git a/android/app/src/main/java/com/audiobookshelf/app/data/LocalMediaProgress.kt b/android/app/src/main/java/com/audiobookshelf/app/data/LocalMediaProgress.kt index 8640ba7f..fa242e3a 100644 --- a/android/app/src/main/java/com/audiobookshelf/app/data/LocalMediaProgress.kt +++ b/android/app/src/main/java/com/audiobookshelf/app/data/LocalMediaProgress.kt @@ -1,12 +1,13 @@ package com.audiobookshelf.app.data +import com.fasterxml.jackson.annotation.JsonIgnore import com.fasterxml.jackson.annotation.JsonIgnoreProperties @JsonIgnoreProperties(ignoreUnknown = true) data class LocalMediaProgress( var id:String, var localLibraryItemId:String, - var episodeId:String?, + var localEpisodeId:String?, var duration:Double, var progress:Double, // 0 to 1 var currentTime:Double, @@ -18,5 +19,27 @@ data class LocalMediaProgress( var serverConnectionConfigId:String?, var serverAddress:String?, var serverUserId:String?, - var libraryItemId:String? -) + var libraryItemId:String?, + var episodeId:String? +) { + @JsonIgnore + fun updateIsFinished(finished:Boolean) { + if (isFinished != finished) { // If finished changed then set progress + progress = if (finished) 1.0 else 0.0 + } + + isFinished = finished + lastUpdate = System.currentTimeMillis() + finishedAt = if (isFinished) lastUpdate else null + } + + @JsonIgnore + fun updateFromPlaybackSession(playbackSession:PlaybackSession) { + currentTime = playbackSession.currentTime + progress = playbackSession.progress + lastUpdate = System.currentTimeMillis() + + isFinished = playbackSession.progress >= 0.99 + finishedAt = if (isFinished) lastUpdate else null + } +} 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 7a04d72c..a6fd088b 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 @@ -40,6 +40,7 @@ class PlaybackSession( var currentTime:Double, var libraryItem:LibraryItem?, var localLibraryItem:LocalLibraryItem?, + var localEpisodeId:String?, var serverConnectionConfigId:String?, var serverAddress:String? ) { @@ -53,7 +54,7 @@ class PlaybackSession( @get:JsonIgnore val localLibraryItemId get() = localLibraryItem?.id ?: "" @get:JsonIgnore - val localMediaProgressId get() = if (episodeId.isNullOrEmpty()) localLibraryItemId else "$localLibraryItemId-$episodeId" + val localMediaProgressId get() = if (episodeId.isNullOrEmpty()) localLibraryItemId else "$localLibraryItemId-$localEpisodeId" @get:JsonIgnore val progress get() = currentTime / getTotalDuration() @@ -175,7 +176,7 @@ class PlaybackSession( @JsonIgnore fun clone():PlaybackSession { - return PlaybackSession(id,userId,libraryItemId,episodeId,mediaType,mediaMetadata,chapters,displayTitle,displayAuthor,coverPath,duration,playMethod,startedAt,updatedAt,timeListening,audioTracks,currentTime,libraryItem,localLibraryItem,serverConnectionConfigId,serverAddress) + return PlaybackSession(id,userId,libraryItemId,episodeId,mediaType,mediaMetadata,chapters,displayTitle,displayAuthor,coverPath,duration,playMethod,startedAt,updatedAt,timeListening,audioTracks,currentTime,libraryItem,localLibraryItem,localEpisodeId,serverConnectionConfigId,serverAddress) } @JsonIgnore @@ -187,6 +188,6 @@ class PlaybackSession( @JsonIgnore fun getNewLocalMediaProgress():LocalMediaProgress { - return LocalMediaProgress(localMediaProgressId,localLibraryItemId,episodeId,getTotalDuration(),progress,currentTime,false,updatedAt,startedAt,null,serverConnectionConfigId,serverAddress,userId,libraryItemId) + return LocalMediaProgress(localMediaProgressId,localLibraryItemId,localEpisodeId,getTotalDuration(),progress,currentTime,false,updatedAt,startedAt,null,serverConnectionConfigId,serverAddress,userId,libraryItemId,episodeId) } } diff --git a/android/app/src/main/java/com/audiobookshelf/app/device/DeviceManager.kt b/android/app/src/main/java/com/audiobookshelf/app/device/DeviceManager.kt index 6e67e3e9..b9cc9cf0 100644 --- a/android/app/src/main/java/com/audiobookshelf/app/device/DeviceManager.kt +++ b/android/app/src/main/java/com/audiobookshelf/app/device/DeviceManager.kt @@ -11,6 +11,7 @@ object DeviceManager { var deviceData:DeviceData = dbManager.getDeviceData() var serverConnectionConfig: ServerConnectionConfig? = null + val serverConnectionConfigId get() = serverConnectionConfig?.id ?: "" val serverAddress get() = serverConnectionConfig?.address ?: "" val serverUserId get() = serverConnectionConfig?.userId ?: "" val token get() = serverConnectionConfig?.token ?: "" @@ -20,6 +21,6 @@ object DeviceManager { } fun getBase64Id(id:String):String { - return android.util.Base64.encodeToString(id.toByteArray(), android.util.Base64.DEFAULT) + return android.util.Base64.encodeToString(id.toByteArray(), android.util.Base64.NO_WRAP) } } 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 1ce1e100..bc82264d 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 @@ -10,11 +10,15 @@ import com.arthenica.ffmpegkit.FFprobeKit import com.arthenica.ffmpegkit.Level import com.audiobookshelf.app.data.* import com.audiobookshelf.app.plugins.AbsDownloader +import com.fasterxml.jackson.core.json.JsonReadFeature import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper import com.fasterxml.jackson.module.kotlin.readValue +import com.getcapacitor.JSObject class FolderScanner(var ctx: Context) { private val tag = "FolderScanner" + var jacksonMapper = jacksonObjectMapper().enable(JsonReadFeature.ALLOW_UNESCAPED_CONTROL_CHARS.mappedFeature()) + private fun getLocalLibraryItemId(mediaItemId:String):String { return "local_" + DeviceManager.getBase64Id(mediaItemId) @@ -52,7 +56,8 @@ class FolderScanner(var ctx: Context) { // Remove existing items no longer there existingLocalLibraryItems = existingLocalLibraryItems.filter { lli -> - var fileFound = foldersFound.find { f -> lli.id == getLocalLibraryItemId(f.id) } + Log.d(tag, "scanForMediaItems Checking Existing LLI ${lli.id}") + var fileFound = foldersFound.find { f -> lli.id == getLocalLibraryItemId(f.id) } if (fileFound == null) { Log.d(tag, "Existing local library item is no longer in file system ${lli.media.metadata.title}") DeviceManager.dbManager.removeLocalLibraryItem(lli.id) @@ -129,25 +134,22 @@ class FolderScanner(var ctx: Context) { 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") + Log.d(tag, "scanLibraryItemFolder 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") + Log.d(tag, "scanLibraryItemFolder 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}") + Log.d(tag, "scanLibraryItemFolder Scanning Audio File Path ${localFile.absolutePath} | ForceAudioProbe=${forceAudioProbe}") // 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}") + var audioProbeResult = probeAudioFile(localFile.absolutePath) if (existingAudioTrack != null) { // Update audio probe data on existing audio track @@ -172,10 +174,12 @@ class FolderScanner(var ctx: Context) { var existingLocalFile = existingLocalFiles.find { elf -> elf.id == localFileId } if (existingLocalFile == null) { + Log.d(tag, "scanLibraryItemFolder new local file found ${localFile.absolutePath}") isNewOrUpdated = true } if (existingItem != null && existingItem.coverContentUrl == null) { // Existing media item did not have a cover - cover found on scan + Log.d(tag, "scanLibraryItemFolder setting cover ${localFile.absolutePath}") isNewOrUpdated = true existingItem.coverAbsolutePath = localFile.absolutePath existingItem.coverContentUrl = localFile.contentUrl @@ -217,11 +221,14 @@ class FolderScanner(var ctx: Context) { fun scanDownloadItem(downloadItem: AbsDownloader.DownloadItem):LocalLibraryItem? { var folderDf = DocumentFileCompat.fromUri(ctx, Uri.parse(downloadItem.localFolder.contentUrl)) var foldersFound = folderDf?.search(false, DocumentFileType.FOLDER) ?: mutableListOf() + + var itemFolderId = "" var itemFolderUrl = "" var itemFolderBasePath = "" var itemFolderAbsolutePath = "" foldersFound.forEach { if (it.name == downloadItem.itemTitle) { + itemFolderId = it.id itemFolderUrl = it.uri.toString() itemFolderBasePath = it.getBasePath(ctx) itemFolderAbsolutePath = it.getAbsolutePath(ctx) @@ -238,7 +245,9 @@ class FolderScanner(var ctx: Context) { Log.e(tag, "Folder Doc File Invalid ${downloadItem.itemFolderPath}") return null } - Log.d(tag, "scanDownloadItem starting for ${downloadItem.itemFolderPath} | ${df.uri}") + + var localLibraryItemId = getLocalLibraryItemId(itemFolderId) + Log.d(tag, "scanDownloadItem starting for ${downloadItem.itemFolderPath} | ${df.uri} | Item Folder Id:$itemFolderId | LLI Id:$localLibraryItemId") // Search for files in media item folder var filesFound = df.search(false, DocumentFileType.FILE, arrayOf("audio/*", "image/*")) @@ -246,13 +255,13 @@ class FolderScanner(var ctx: Context) { 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) + localLibraryItem = LocalLibraryItem(localLibraryItemId, downloadItem.localFolder.id, itemFolderBasePath, itemFolderAbsolutePath, itemFolderUrl, false, downloadItem.mediaType, downloadItem.media.getLocalCopy(), mutableListOf(), null, null, true, downloadItem.serverConnectionConfigId, downloadItem.serverAddress, downloadItem.serverUserId, downloadItem.libraryItemId) } else { // Lookup or create podcast local library item - localLibraryItem = DeviceManager.dbManager.getLocalLibraryItem("local_${downloadItem.libraryItemId}") + localLibraryItem = DeviceManager.dbManager.getLocalLibraryItem(localLibraryItemId) 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) + Log.d(tag, "[FolderScanner] Podcast local library item not created yet for ${downloadItem.media.metadata.title}") + localLibraryItem = LocalLibraryItem(localLibraryItemId, downloadItem.localFolder.id, itemFolderBasePath, itemFolderAbsolutePath, itemFolderUrl, false, downloadItem.mediaType, downloadItem.media.getLocalCopy(), mutableListOf(), null, null, true,downloadItem.serverConnectionConfigId,downloadItem.serverAddress,downloadItem.serverUserId,downloadItem.libraryItemId) } } @@ -268,25 +277,26 @@ class FolderScanner(var ctx: Context) { } } else if (itemPart.audioTrack != null) { // Is audio track var audioTrackFromServer = itemPart.audioTrack + Log.d(tag, "scanDownloadItem: Audio Track from Server index = ${audioTrackFromServer?.index}") 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}") + var audioProbeResult = probeAudioFile(localFile.absolutePath) // 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) + Log.d(tag, "scanDownloadItem: Created Audio Track with index ${track.index} from local file ${localFile.absolutePath}") + // Add podcast episodes to library itemPart.episode?.let { podcastEpisode -> var podcast = localLibraryItem.media as Podcast podcast.addEpisode(track, podcastEpisode) + Log.d(tag, "scanDownloadItem: Added episode to podcast ${podcastEpisode.title} ${track.title} | Track index: ${podcastEpisode.audioTrack?.index}") } } else { // Cover image var localFileId = DeviceManager.getBase64Id(docFile.id) @@ -381,10 +391,7 @@ class FolderScanner(var ctx: Context) { if (localFile.isAudioFile()) { // 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}") + var audioProbeResult = probeAudioFile(localFile.absolutePath) var existingTrack = existingAudioTracks.find { audioTrack -> audioTrack.localFileId == localFile.id @@ -421,4 +428,13 @@ class FolderScanner(var ctx: Context) { } return LocalLibraryItemScanResult(wasUpdated, localLibraryItem) } + + fun probeAudioFile(absolutePath:String):AudioProbeResult { + var session = FFprobeKit.execute("-i \"${absolutePath}\" -print_format json -show_format -show_streams -select_streams a -show_chapters -loglevel quiet") + Log.d(tag, "FFprobe output ${JSObject(session.output)}") + + val audioProbeResult = jacksonMapper.readValue(session.output) + Log.d(tag, "Probe Result DATA ${audioProbeResult.duration} | ${audioProbeResult.size} | ${audioProbeResult.title} | ${audioProbeResult.artist}") + return audioProbeResult + } } diff --git a/android/app/src/main/java/com/audiobookshelf/app/player/CastManager.kt b/android/app/src/main/java/com/audiobookshelf/app/player/CastManager.kt index 30b349a0..ead754f5 100644 --- a/android/app/src/main/java/com/audiobookshelf/app/player/CastManager.kt +++ b/android/app/src/main/java/com/audiobookshelf/app/player/CastManager.kt @@ -2,6 +2,7 @@ package com.audiobookshelf.app.player import android.app.Activity import android.app.AlertDialog +import android.net.Uri import android.os.Bundle import android.util.Log import androidx.appcompat.R @@ -13,10 +14,7 @@ import com.google.android.exoplayer2.MediaItem import com.google.android.exoplayer2.ext.cast.CastPlayer import com.google.android.exoplayer2.ext.cast.MediaItemConverter import com.google.android.exoplayer2.ext.cast.SessionAvailabilityListener -import com.google.android.gms.cast.Cast -import com.google.android.gms.cast.CastDevice -import com.google.android.gms.cast.CastMediaControlIntent -import com.google.android.gms.cast.MediaQueueItem +import com.google.android.gms.cast.* import com.google.android.gms.cast.framework.* import org.json.JSONObject @@ -321,12 +319,36 @@ class CastManager constructor(playerNotificationService:PlayerNotificationServic try { val castContext = CastContext.getSharedInstance(mainActivity) - playerNotificationService.castPlayer = CastPlayer(castContext, CustomConverter()).apply { - setSessionAvailabilityListener(CastSessionAvailabilityListener()) - addListener(PlayerListener(playerNotificationService)) + // Work in progress using the cast api + var currentSession = playerNotificationService.getCurrentPlaybackSessionCopy() + var firstTrack = currentSession?.audioTracks?.get(0) + var uri = firstTrack?.let { currentSession?.getContentUri(it) } ?: Uri.EMPTY + var url = uri.toString() + var mimeType = firstTrack?.mimeType ?: "" + var castMediaMetadata = firstTrack?.let { currentSession?.getCastMediaMetadata(it) } + Log.d(tag, "CastManager set url $url") + var duration = (currentSession?.getTotalDuration() ?: 0L * 1000L).toLong() + + if (castMediaMetadata != null) { + Log.d(tag, "CastManager duration $duration got cast media metadata $castMediaMetadata") + + val mediaInfo = MediaInfo.Builder(url) + .setStreamType(MediaInfo.STREAM_TYPE_BUFFERED) + .setContentType(mimeType) + .setMetadata(castMediaMetadata) + .setStreamDuration(duration) + .build() + val remoteMediaClient = castSession?.remoteMediaClient + remoteMediaClient?.load(MediaLoadRequestData.Builder().setMediaInfo(mediaInfo).build()) } - Log.d(tag, "CAST Cast Player Applied") - switchToPlayer(true) + + // Not working using the exo player CastPlayer +// playerNotificationService.castPlayer = CastPlayer(castContext, CustomConverter()).apply { +// setSessionAvailabilityListener(CastSessionAvailabilityListener()) +// addListener(PlayerListener(playerNotificationService)) +// } +// Log.d(tag, "CAST Cast Player Applied") +// switchToPlayer(true) } catch (e: Exception) { Log.i(tag, "Cast is not available on this device. " + "Exception thrown when attempting to obtain CastContext. " + e.message) diff --git a/android/app/src/main/java/com/audiobookshelf/app/player/MediaProgressSyncer.kt b/android/app/src/main/java/com/audiobookshelf/app/player/MediaProgressSyncer.kt index 60cfe8e1..6b3f0b51 100644 --- a/android/app/src/main/java/com/audiobookshelf/app/player/MediaProgressSyncer.kt +++ b/android/app/src/main/java/com/audiobookshelf/app/player/MediaProgressSyncer.kt @@ -110,14 +110,12 @@ class MediaProgressSyncer(playerNotificationService:PlayerNotificationService, a currentLocalMediaProgress = mediaProgress } } else { - currentLocalMediaProgress?.currentTime = playbackSession.currentTime - currentLocalMediaProgress?.lastUpdate = playbackSession.updatedAt - currentLocalMediaProgress?.progress = playbackSession.progress + currentLocalMediaProgress?.updateFromPlaybackSession(playbackSession) } currentLocalMediaProgress?.let { DeviceManager.dbManager.saveLocalMediaProgress(it) playerNotificationService.clientEventEmitter?.onLocalMediaProgressUpdate(it) - Log.d(tag, "Saved Local Progress Current Time: ${it.currentTime} | Duration ${it.duration} | Progress ${(it.progress * 100).roundToInt()}%") + Log.d(tag, "Saved Local Progress Current Time: ID ${it.id} | ${it.currentTime} | Duration ${it.duration} | Progress ${(it.progress * 100).roundToInt()}%") } } 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 aa155b28..552622b9 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 @@ -6,9 +6,7 @@ import android.os.Looper import android.util.Log import androidx.core.content.ContextCompat import com.audiobookshelf.app.MainActivity -import com.audiobookshelf.app.data.LocalMediaProgress -import com.audiobookshelf.app.data.PlaybackMetadata -import com.audiobookshelf.app.data.PlaybackSession +import com.audiobookshelf.app.data.* import com.audiobookshelf.app.device.DeviceManager import com.audiobookshelf.app.player.CastManager import com.audiobookshelf.app.player.PlayerNotificationService @@ -22,6 +20,7 @@ import org.json.JSONObject @CapacitorPlugin(name = "AbsAudioPlayer") class AbsAudioPlayer : Plugin() { private val tag = "AbsAudioPlayer" + var jacksonMapper = jacksonObjectMapper().enable(JsonReadFeature.ALLOW_UNESCAPED_CONTROL_CHARS.mappedFeature()) lateinit var mainActivity: MainActivity lateinit var apiHandler:ApiHandler @@ -36,7 +35,7 @@ class AbsAudioPlayer : Plugin() { playerNotificationService.clientEventEmitter = (object : PlayerNotificationService.ClientEventEmitter { override fun onPlaybackSession(playbackSession: PlaybackSession) { - notifyListeners("onPlaybackSession", JSObject(jacksonObjectMapper().writeValueAsString(playbackSession))) + notifyListeners("onPlaybackSession", JSObject(jacksonMapper.writeValueAsString(playbackSession))) } override fun onPlaybackClosed() { @@ -48,7 +47,7 @@ class AbsAudioPlayer : Plugin() { } override fun onMetadata(metadata: PlaybackMetadata) { - notifyListeners("onMetadata", JSObject(jacksonObjectMapper().writeValueAsString(metadata))) + notifyListeners("onMetadata", JSObject(jacksonMapper.writeValueAsString(metadata))) } override fun onPrepare(audiobookId: String, playWhenReady: Boolean) { @@ -67,7 +66,7 @@ class AbsAudioPlayer : Plugin() { } override fun onLocalMediaProgressUpdate(localMediaProgress: LocalMediaProgress) { - notifyListeners("onLocalMediaProgressUpdate", JSObject(jacksonObjectMapper().writeValueAsString(localMediaProgress))) + notifyListeners("onLocalMediaProgressUpdate", JSObject(jacksonMapper.writeValueAsString(localMediaProgress))) } }) } @@ -101,9 +100,19 @@ class AbsAudioPlayer : Plugin() { if (libraryItemId.startsWith("local")) { // Play local media item DeviceManager.dbManager.getLocalLibraryItem(libraryItemId)?.let { + var episode: PodcastEpisode? = null + if (!episodeId.isNullOrEmpty()) { + var podcastMedia = it.media as Podcast + episode = podcastMedia.episodes?.find { ep -> ep.id == episodeId } + if (episode == null) { + Log.e(tag, "prepareLibraryItem: Podcast episode not found $episodeId") + return call.resolve(JSObject()) + } + } + Handler(Looper.getMainLooper()).post() { - Log.d(tag, "Preparing Local Media item ${jacksonObjectMapper().writeValueAsString(it)}") - var playbackSession = it.getPlaybackSession(episodeId) + Log.d(tag, "prepareLibraryItem: Preparing Local Media item ${jacksonMapper.writeValueAsString(it)}") + var playbackSession = it.getPlaybackSession(episode) playerNotificationService.preparePlayer(playbackSession, playWhenReady) } return call.resolve(JSObject()) @@ -112,11 +121,11 @@ class AbsAudioPlayer : Plugin() { apiHandler.playLibraryItem(libraryItemId, episodeId, false) { Handler(Looper.getMainLooper()).post() { - Log.d(tag, "Preparing Player TEST ${jacksonObjectMapper().writeValueAsString(it)}") + Log.d(tag, "Preparing Player TEST ${jacksonMapper.writeValueAsString(it)}") playerNotificationService.preparePlayer(it, playWhenReady) } - call.resolve(JSObject(jacksonObjectMapper().writeValueAsString(it))) + call.resolve(JSObject(jacksonMapper.writeValueAsString(it))) } } } diff --git a/android/app/src/main/java/com/audiobookshelf/app/plugins/AbsDatabase.kt b/android/app/src/main/java/com/audiobookshelf/app/plugins/AbsDatabase.kt index 0a16d887..cb79822e 100644 --- a/android/app/src/main/java/com/audiobookshelf/app/plugins/AbsDatabase.kt +++ b/android/app/src/main/java/com/audiobookshelf/app/plugins/AbsDatabase.kt @@ -4,6 +4,7 @@ import android.util.Log import com.audiobookshelf.app.MainActivity import com.audiobookshelf.app.device.DeviceManager 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 @@ -15,6 +16,7 @@ import org.json.JSONObject @CapacitorPlugin(name = "AbsDatabase") class AbsDatabase : Plugin() { val tag = "AbsDatabase" + var jacksonMapper = jacksonObjectMapper().enable(JsonReadFeature.ALLOW_UNESCAPED_CONTROL_CHARS.mappedFeature()) lateinit var mainActivity: MainActivity lateinit var apiHandler: ApiHandler @@ -26,13 +28,16 @@ class AbsDatabase : Plugin() { override fun load() { mainActivity = (activity as MainActivity) apiHandler = ApiHandler(mainActivity) + + DeviceManager.dbManager.cleanLocalMediaProgress() + DeviceManager.dbManager.cleanLocalLibraryItems() } @PluginMethod fun getDeviceData(call:PluginCall) { GlobalScope.launch(Dispatchers.IO) { var deviceData = DeviceManager.dbManager.getDeviceData() - call.resolve(JSObject(jacksonObjectMapper().writeValueAsString(deviceData))) + call.resolve(JSObject(jacksonMapper.writeValueAsString(deviceData))) } } @@ -40,7 +45,7 @@ class AbsDatabase : Plugin() { fun getLocalFolders(call:PluginCall) { GlobalScope.launch(Dispatchers.IO) { var folders = DeviceManager.dbManager.getAllLocalFolders() - call.resolve(JSObject(jacksonObjectMapper().writeValueAsString(LocalFoldersPayload(folders)))) + call.resolve(JSObject(jacksonMapper.writeValueAsString(LocalFoldersPayload(folders)))) } } @@ -49,7 +54,7 @@ class AbsDatabase : Plugin() { var folderId = call.getString("folderId", "").toString() GlobalScope.launch(Dispatchers.IO) { DeviceManager.dbManager.getLocalFolder(folderId)?.let { - var folderObj = jacksonObjectMapper().writeValueAsString(it) + var folderObj = jacksonMapper.writeValueAsString(it) call.resolve(JSObject(folderObj)) } ?: call.resolve() } @@ -64,7 +69,7 @@ class AbsDatabase : Plugin() { if (localLibraryItem == null) { call.resolve() } else { - call.resolve(JSObject(jacksonObjectMapper().writeValueAsString(localLibraryItem))) + call.resolve(JSObject(jacksonMapper.writeValueAsString(localLibraryItem))) } } } @@ -77,7 +82,7 @@ class AbsDatabase : Plugin() { if (localLibraryItem == null) { call.resolve() } else { - call.resolve(JSObject(jacksonObjectMapper().writeValueAsString(localLibraryItem))) + call.resolve(JSObject(jacksonMapper.writeValueAsString(localLibraryItem))) } } } @@ -88,7 +93,7 @@ class AbsDatabase : Plugin() { GlobalScope.launch(Dispatchers.IO) { var localLibraryItems = DeviceManager.dbManager.getLocalLibraryItems(mediaType) - call.resolve(JSObject(jacksonObjectMapper().writeValueAsString(LocalLibraryItemsPayload(localLibraryItems)))) + call.resolve(JSObject(jacksonMapper.writeValueAsString(LocalLibraryItemsPayload(localLibraryItems)))) } } @@ -97,7 +102,7 @@ class AbsDatabase : Plugin() { var folderId = call.getString("folderId", "").toString() GlobalScope.launch(Dispatchers.IO) { var localLibraryItems = DeviceManager.dbManager.getLocalLibraryItemsInFolder(folderId) - call.resolve(JSObject(jacksonObjectMapper().writeValueAsString(LocalLibraryItemsPayload(localLibraryItems)))) + call.resolve(JSObject(jacksonMapper.writeValueAsString(LocalLibraryItemsPayload(localLibraryItems)))) } } @@ -143,7 +148,7 @@ class AbsDatabase : Plugin() { } DeviceManager.serverConnectionConfig = serverConnectionConfig - call.resolve(JSObject(jacksonObjectMapper().writeValueAsString(DeviceManager.serverConnectionConfig))) + call.resolve(JSObject(jacksonMapper.writeValueAsString(DeviceManager.serverConnectionConfig))) } } @@ -177,7 +182,7 @@ class AbsDatabase : Plugin() { fun getAllLocalMediaProgress(call:PluginCall) { GlobalScope.launch(Dispatchers.IO) { var localMediaProgress = DeviceManager.dbManager.getAllLocalMediaProgress() - call.resolve(JSObject(jacksonObjectMapper().writeValueAsString(LocalMediaProgressPayload(localMediaProgress)))) + call.resolve(JSObject(jacksonMapper.writeValueAsString(LocalMediaProgressPayload(localMediaProgress)))) } } @@ -195,7 +200,51 @@ class AbsDatabase : Plugin() { return call.resolve() } apiHandler.syncMediaProgress { - call.resolve(JSObject(jacksonObjectMapper().writeValueAsString(it))) + call.resolve(JSObject(jacksonMapper.writeValueAsString(it))) + } + } + + @PluginMethod + fun updateLocalMediaProgressFinished(call:PluginCall) { + var localMediaProgressId = call.getString("localMediaProgressId", "").toString() + var isFinished = call.getBoolean("isFinished", false) == true + Log.d(tag, "updateLocalMediaProgressFinished $localMediaProgressId | Is Finished:$isFinished") + var localMediaProgress = DeviceManager.dbManager.getLocalMediaProgress(localMediaProgressId) + if (localMediaProgress == null) { + Log.e(tag, "updateLocalMediaProgressFinished Local Media Progress not found $localMediaProgressId") + call.resolve(JSObject("{\"error\":\"Progress not found\"}")) + } else { + localMediaProgress.updateIsFinished(isFinished) + + var lmpstring = jacksonMapper.writeValueAsString(localMediaProgress) + Log.d(tag, "updateLocalMediaProgressFinished: Local Media Progress String $lmpstring") + + // Send update to server media progress is linked to a server and user is logged into that server + localMediaProgress.serverConnectionConfigId?.let { configId -> + if (DeviceManager.serverConnectionConfigId == configId) { + var libraryItemId = localMediaProgress.libraryItemId ?: "" + var episodeId = localMediaProgress.episodeId ?: "" + var updatePayload = JSObject() + updatePayload.put("isFinished", isFinished) + apiHandler.updateMediaProgress(libraryItemId,episodeId,updatePayload) { + Log.d(tag, "updateLocalMediaProgressFinished: Updated media progress isFinished on server") + var jsobj = JSObject() + jsobj.put("local", true) + jsobj.put("server", true) + jsobj.put("localMediaProgress", JSObject(lmpstring)) + call.resolve(jsobj) +// call.resolve(JSObject("{\"local\":true,\"server\":true,\"localMediaProgress\":$lmpstring}")) + } + } + } + if (localMediaProgress.serverConnectionConfigId == null || DeviceManager.serverConnectionConfigId != localMediaProgress.serverConnectionConfigId) { +// call.resolve(JSObject("{\"local\":true,\"localMediaProgress\":$lmpstring}}")) + var jsobj = JSObject() + jsobj.put("local", true) + jsobj.put("server", false) + jsobj.put("localMediaProgress", JSObject(lmpstring)) + call.resolve(jsobj) + } } } @@ -208,16 +257,36 @@ class AbsDatabase : Plugin() { return } + var audioTracks = localLibraryItem.media.getAudioTracks() as MutableList + var tracks:JSArray = call.getArray("tracks") ?: JSArray() Log.d(tag, "updateLocalTrackOrder $tracks") - for (i in 0..tracks.length()) { + var index = 1 + var hasUpdates = false + for (i in 0 until tracks.length()) { var track = tracks.getJSONObject(i) var localFileId = track.getString("localFileId") - Log.d(tag, "LOCAL FILE ID $localFileId") + + var existingTrack = audioTracks.find{ it.localFileId == localFileId } + if (existingTrack != null) { + Log.d(tag, "Found existing track ${existingTrack.localFileId} that has index ${existingTrack.index} should be index $index") + if (existingTrack.index != index) hasUpdates = true + existingTrack.index = index++ + } else { + Log.e(tag, "Audio track with local file id not found") + } } - call.resolve() + if (hasUpdates) { + Log.d(tag, "Save library item track orders") + localLibraryItem.media.setAudioTracks(audioTracks) + DeviceManager.dbManager.saveLocalLibraryItem(localLibraryItem) + call.resolve(JSObject(jacksonMapper.writeValueAsString(localLibraryItem))) + } else { + Log.d(tag, "No tracks need to be updated") + call.resolve() + } } // 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 ce17a0d2..a1d65d06 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 @@ -11,6 +11,7 @@ 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.core.json.JsonReadFeature import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper import com.getcapacitor.JSObject import com.getcapacitor.Plugin @@ -28,6 +29,7 @@ import java.util.* @CapacitorPlugin(name = "AbsDownloader") class AbsDownloader : Plugin() { private val tag = "AbsDownloader" + var jacksonMapper = jacksonObjectMapper().enable(JsonReadFeature.ALLOW_UNESCAPED_CONTROL_CHARS.mappedFeature()) lateinit var mainActivity: MainActivity lateinit var downloadManager: DownloadManager @@ -294,7 +296,7 @@ class AbsDownloader : Plugin() { DeviceManager.dbManager.saveDownloadItem(downloadItem) } - notifyListeners("onItemDownloadUpdate", JSObject(jacksonObjectMapper().writeValueAsString(downloadItem))) + notifyListeners("onItemDownloadUpdate", JSObject(jacksonMapper.writeValueAsString(downloadItem))) delay(500) } @@ -308,7 +310,7 @@ class AbsDownloader : Plugin() { jsobj.put("libraryItemId", downloadItem.id) jsobj.put("localFolderId", downloadItem.localFolder.id) if (localLibraryItem != null) { - jsobj.put("localLibraryItem", JSObject(jacksonObjectMapper().writeValueAsString(localLibraryItem))) + jsobj.put("localLibraryItem", JSObject(jacksonMapper.writeValueAsString(localLibraryItem))) } notifyListeners("onItemDownloadComplete", jsobj) } diff --git a/android/app/src/main/java/com/audiobookshelf/app/plugins/AbsFileSystem.kt b/android/app/src/main/java/com/audiobookshelf/app/plugins/AbsFileSystem.kt index 556e481e..a0cfed27 100644 --- a/android/app/src/main/java/com/audiobookshelf/app/plugins/AbsFileSystem.kt +++ b/android/app/src/main/java/com/audiobookshelf/app/plugins/AbsFileSystem.kt @@ -15,6 +15,7 @@ import com.audiobookshelf.app.data.LocalFolder import com.audiobookshelf.app.data.LocalLibraryItem import com.audiobookshelf.app.device.DeviceManager import com.audiobookshelf.app.device.FolderScanner +import com.fasterxml.jackson.core.json.JsonReadFeature import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper import com.getcapacitor.* import com.getcapacitor.annotation.CapacitorPlugin @@ -26,6 +27,7 @@ import kotlinx.coroutines.launch class AbsFileSystem : Plugin() { private val TAG = "AbsFileSystem" private val tag = "AbsFileSystem" + var jacksonMapper = jacksonObjectMapper().enable(JsonReadFeature.ALLOW_UNESCAPED_CONTROL_CHARS.mappedFeature()) lateinit var mainActivity: MainActivity @@ -77,7 +79,7 @@ class AbsFileSystem : Plugin() { var localFolder = LocalFolder(folderId, folder.name ?: "", folder.uri.toString(),basePath,absolutePath, simplePath, storageType.toString(), mediaType) DeviceManager.dbManager.saveLocalFolder(localFolder) - call.resolve(JSObject(jacksonObjectMapper().writeValueAsString(localFolder))) + call.resolve(JSObject(jacksonMapper.writeValueAsString(localFolder))) } override fun onStorageAccessDenied(requestCode: Int, folder: DocumentFile?, storageType: StorageType) { @@ -148,8 +150,8 @@ class AbsFileSystem : Plugin() { Log.d(TAG, "NO Scan DATA") return call.resolve(JSObject()) } else { - Log.d(TAG, "Scan DATA ${jacksonObjectMapper().writeValueAsString(folderScanResult)}") - return call.resolve(JSObject(jacksonObjectMapper().writeValueAsString(folderScanResult))) + Log.d(TAG, "Scan DATA ${jacksonMapper.writeValueAsString(folderScanResult)}") + return call.resolve(JSObject(jacksonMapper.writeValueAsString(folderScanResult))) } } ?: call.resolve(JSObject()) } @@ -182,8 +184,8 @@ class AbsFileSystem : Plugin() { Log.d(TAG, "NO Scan DATA") call.resolve(JSObject()) } else { - Log.d(TAG, "Scan DATA ${jacksonObjectMapper().writeValueAsString(scanResult)}") - call.resolve(JSObject(jacksonObjectMapper().writeValueAsString(scanResult))) + Log.d(TAG, "Scan DATA ${jacksonMapper.writeValueAsString(scanResult)}") + call.resolve(JSObject(jacksonMapper.writeValueAsString(scanResult))) } } ?: call.resolve(JSObject()) } @@ -223,7 +225,7 @@ class AbsFileSystem : Plugin() { localLibraryItem?.media?.removeAudioTrack(trackLocalFileId) localLibraryItem?.removeLocalFile(trackLocalFileId) DeviceManager.dbManager.saveLocalLibraryItem(localLibraryItem) - call.resolve(JSObject(jacksonObjectMapper().writeValueAsString(localLibraryItem))) + call.resolve(JSObject(jacksonMapper.writeValueAsString(localLibraryItem))) } else { call.resolve(JSObject("{\"success\":false}")) } 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 d195e475..bdcd2c0f 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 @@ -13,6 +13,7 @@ import com.audiobookshelf.app.data.PlaybackSession import com.audiobookshelf.app.device.DeviceManager import com.audiobookshelf.app.player.MediaProgressSyncData import com.fasterxml.jackson.annotation.JsonIgnoreProperties +import com.fasterxml.jackson.core.json.JsonReadFeature import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper import com.fasterxml.jackson.module.kotlin.readValue import com.getcapacitor.JSArray @@ -26,6 +27,7 @@ import java.io.IOException class ApiHandler { val tag = "ApiHandler" private var client = OkHttpClient() + var jacksonMapper = jacksonObjectMapper().enable(JsonReadFeature.ALLOW_UNESCAPED_CONTROL_CHARS.mappedFeature()) var ctx: Context var storageSharedPreferences: SharedPreferences? = null @@ -54,6 +56,15 @@ class ApiHandler { makeRequest(request, cb) } + fun patchRequest(endpoint:String, payload: JSObject, cb: (JSObject) -> Unit) { + val mediaType = "application/json; charset=utf-8".toMediaType() + val requestBody = payload.toString().toRequestBody(mediaType) + val request = Request.Builder().patch(requestBody) + .url("${DeviceManager.serverAddress}$endpoint").addHeader("Authorization", "Bearer ${DeviceManager.token}") + .build() + makeRequest(request, cb) + } + fun isOnline(): Boolean { val connectivityManager = ctx.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager if (connectivityManager != null) { @@ -105,7 +116,7 @@ class ApiHandler { } fun getLibraries(cb: (List) -> Unit) { - val mapper = jacksonObjectMapper() + val mapper = jacksonMapper getRequest("/api/libraries") { val libraries = mutableListOf() if (it.has("value")) { @@ -121,7 +132,7 @@ class ApiHandler { fun getLibraryItem(libraryItemId:String, cb: (LibraryItem) -> Unit) { getRequest("/api/items/$libraryItemId?expanded=1") { - val libraryItem = jacksonObjectMapper().readValue(it.toString()) + val libraryItem = jacksonMapper.readValue(it.toString()) cb(libraryItem) } } @@ -132,7 +143,7 @@ class ApiHandler { if (it.has("results")) { var array = it.getJSONArray("results") for (i in 0 until array.length()) { - val item = jacksonObjectMapper().readValue(array.get(i).toString()) + val item = jacksonMapper.readValue(array.get(i).toString()) items.add(item) } } @@ -153,13 +164,13 @@ class ApiHandler { postRequest(endpoint, payload) { it.put("serverConnectionConfigId", DeviceManager.serverConnectionConfig?.id) it.put("serverAddress", DeviceManager.serverAddress) - val playbackSession = jacksonObjectMapper().readValue(it.toString()) + val playbackSession = jacksonMapper.readValue(it.toString()) cb(playbackSession) } } fun sendProgressSync(sessionId:String, syncData: MediaProgressSyncData, cb: () -> Unit) { - var payload = JSObject(jacksonObjectMapper().writeValueAsString(syncData)) + var payload = JSObject(jacksonMapper.writeValueAsString(syncData)) postRequest("/api/session/$sessionId/sync", payload) { cb() @@ -167,7 +178,7 @@ class ApiHandler { } fun sendLocalProgressSync(playbackSession:PlaybackSession, cb: () -> Unit) { - var payload = JSObject(jacksonObjectMapper().writeValueAsString(playbackSession)) + var payload = JSObject(jacksonMapper.writeValueAsString(playbackSession)) postRequest("/api/session/local", payload) { cb() @@ -190,14 +201,14 @@ class ApiHandler { if (localMediaProgress.isNotEmpty()) { Log.d(tag, "Sending sync local progress request with ${localMediaProgress.size} progress items") - var payload = JSObject(jacksonObjectMapper().writeValueAsString(LocalMediaProgressSyncPayload(localMediaProgress))) + var payload = JSObject(jacksonMapper.writeValueAsString(LocalMediaProgressSyncPayload(localMediaProgress))) postRequest("/api/me/sync-local-progress", payload) { Log.d(tag, "Media Progress Sync payload $payload - response ${it.toString()}") if (it.toString() == "{}") { Log.e(tag, "Progress sync received empty object") } else { - val progressSyncResponsePayload = jacksonObjectMapper().readValue(it.toString()) + val progressSyncResponsePayload = jacksonMapper.readValue(it.toString()) localSyncResultsPayload.numLocalProgressUpdates = progressSyncResponsePayload.localProgressUpdates.size localSyncResultsPayload.numServerProgressUpdates = progressSyncResponsePayload.numServerProgressUpdates @@ -217,4 +228,13 @@ class ApiHandler { cb(localSyncResultsPayload) } } + + fun updateMediaProgress(libraryItemId:String,episodeId:String?,updatePayload:JSObject, cb: () -> Unit) { + Log.d(tag, "updateMediaProgress $libraryItemId $episodeId $updatePayload") + var endpoint = if(episodeId.isNullOrEmpty()) "/api/me/progress/$libraryItemId" else "/api/me/progress/$libraryItemId/$episodeId" + patchRequest(endpoint,updatePayload) { + Log.d(tag, "updateMediaProgress patched progress") + cb() + } + } } diff --git a/components/app/Appbar.vue b/components/app/Appbar.vue index c42cece6..9c76e278 100644 --- a/components/app/Appbar.vue +++ b/components/app/Appbar.vue @@ -17,7 +17,7 @@ - + search diff --git a/components/app/AudioPlayer.vue b/components/app/AudioPlayer.vue index ca2ddda8..eea3cdea 100644 --- a/components/app/AudioPlayer.vue +++ b/components/app/AudioPlayer.vue @@ -43,7 +43,10 @@
- {{ bookmarks.length ? 'bookmark' : 'bookmark_border' }} + {{ bookmarks.length ? 'bookmark' : 'bookmark_border' }} + + bookmark + {{ currentPlaybackRate }}x @@ -156,6 +159,12 @@ export default { } return this.showFullscreen ? 200 : 60 }, + mediaType() { + return this.playbackSession ? this.playbackSession.mediaType : null + }, + isPodcast() { + return this.mediaType === 'podcast' + }, mediaMetadata() { return this.playbackSession ? this.playbackSession.mediaMetadata : null }, @@ -552,6 +561,10 @@ export default { } this.isEnded = data.playerState == this.$constants.PlayerState.ENDED + console.log('received metadata update', data) + + if (data.currentRate && data.currentRate > 0) this.playbackSpeed = data.currentRate + this.timeupdate() }, // When a playback session is started the native android/ios will send the session diff --git a/components/app/SideDrawer.vue b/components/app/SideDrawer.vue index b62e11ab..c58d9cf5 100644 --- a/components/app/SideDrawer.vue +++ b/components/app/SideDrawer.vue @@ -101,12 +101,14 @@ export default { }) } - items.push({ - icon: 'folder', - iconOutlined: true, - text: 'Local Media', - to: '/localMedia/folders' - }) + if (this.$platform !== 'ios') { + items.push({ + icon: 'folder', + iconOutlined: true, + text: 'Local Media', + to: '/localMedia/folders' + }) + } return items }, currentRoutePath() { diff --git a/components/covers/BookCover.vue b/components/covers/BookCover.vue index 60b04f80..b6faba6c 100644 --- a/components/covers/BookCover.vue +++ b/components/covers/BookCover.vue @@ -120,7 +120,7 @@ export default { return this.media.coverPath || this.placeholderUrl }, hasCover() { - return !!this.media.coverPath || this.localCover + return !!this.media.coverPath || this.localCover || this.downloadCover }, sizeMultiplier() { var baseSize = this.squareAspectRatio ? 192 : 120 diff --git a/components/tables/podcast/EpisodeRow.vue b/components/tables/podcast/EpisodeRow.vue index 24c8e0c9..984afee8 100644 --- a/components/tables/podcast/EpisodeRow.vue +++ b/components/tables/podcast/EpisodeRow.vue @@ -1,29 +1,35 @@
-
- -
-

Published {{ $formatDate(publishedAt, 'MMM do, yyyy') }}

+
+ + -

- {{ title }} -

-

- {{ description }} -

-
-
- {{ streamIsPlaying ? 'pause' : 'play_arrow' }} -

{{ timeRemaining }}

-
+

Published {{ $formatDate(publishedAt, 'MMM do, yyyy') }}

- - {{ downloadItem ? 'downloading' : 'download' }} +

+ {{ title }} +

+

+ {{ description }} +

+
+
+ {{ streamIsPlaying ? 'pause' : 'play_arrow' }} +

{{ timeRemaining }}

+ + + + audio_file + {{ downloadItem ? 'downloading' : 'download' }} + download_done
@@ -38,11 +44,16 @@ import { AbsDownloader } from '@/plugins/capacitor' export default { props: { libraryItemId: String, - isLocal: Boolean, episode: { type: Object, default: () => {} - } + }, + localLibraryItemId: String, + localEpisode: { + type: Object, + default: () => {} + }, + isLocal: Boolean }, data() { return { @@ -68,8 +79,15 @@ export default { return this.$secondsToTimestamp(this.episode.duration) }, isStreaming() { + if (this.playerIsLocal && this.localLibraryItemId && this.localEpisode) { + // Check is streaming local version of this episode + return this.$store.getters['getIsEpisodeStreaming'](this.localLibraryItemId, this.localEpisode.id) + } return this.$store.getters['getIsEpisodeStreaming'](this.libraryItemId, this.episode.id) }, + playerIsLocal() { + return !!this.$store.state.playerIsLocal + }, streamIsPlaying() { return this.$store.state.playerIsPlaying && this.isStreaming }, @@ -77,6 +95,14 @@ export default { if (this.isLocal) return this.$store.getters['globals/getLocalMediaProgressById'](this.libraryItemId, this.episode.id) return this.$store.getters['user/getUserMediaProgress'](this.libraryItemId, this.episode.id) }, + localMediaProgress() { + if (this.isLocal) return this.$store.getters['globals/getLocalMediaProgressById'](this.libraryItemId, this.episode.id) + else if (this.localLibraryItemId && this.localEpisode) { + return this.$store.getters['globals/getLocalMediaProgressById'](this.localLibraryItemId, this.localEpisode.id) + } else { + return null + } + }, itemProgressPercent() { return this.itemProgress ? this.itemProgress.progress : 0 }, @@ -95,6 +121,9 @@ export default { }, downloadItem() { return this.$store.getters['globals/getDownloadItem'](this.libraryItemId, this.episode.id) + }, + localEpisodeId() { + return this.localEpisode ? this.localEpisode.id : null } }, methods: { @@ -154,30 +183,69 @@ export default { if (this.streamIsPlaying) { this.$eventBus.$emit('pause-item') } else { - this.$eventBus.$emit('play-item', { - libraryItemId: this.libraryItemId, - episodeId: this.episode.id - }) + if (this.localEpisode && this.localLibraryItemId) { + console.log('Play local episode', this.localEpisode.id, this.localLibraryItemId) + + this.$eventBus.$emit('play-item', { + libraryItemId: this.localLibraryItemId, + episodeId: this.localEpisode.id + }) + } else { + this.$eventBus.$emit('play-item', { + libraryItemId: this.libraryItemId, + episodeId: this.episode.id + }) + } } }, - toggleFinished() { - var updatePayload = { - isFinished: !this.userIsFinished - } + async toggleFinished() { this.isProcessingReadUpdate = true - this.$axios - .$patch(`/api/me/progress/${this.libraryItemId}/${this.episode.id}`, updatePayload) - .then(() => { - this.isProcessingReadUpdate = false - this.$toast.success(`Item marked as ${updatePayload.isFinished ? 'Finished' : 'Not Finished'}`) - }) - .catch((error) => { - console.error('Failed', error) - this.isProcessingReadUpdate = false - this.$toast.error(`Failed to mark as ${updatePayload.isFinished ? 'Finished' : 'Not Finished'}`) - }) + if (this.isLocal || this.localEpisode) { + var isFinished = !this.userIsFinished + var localLibraryItemId = this.isLocal ? this.libraryItemId : this.localLibraryItemId + var localEpisodeId = this.isLocal ? this.episode.id : this.localEpisode.id + var localMediaProgressId = `${localLibraryItemId}-${localEpisodeId}` + console.log('toggleFinished local media progress id', localMediaProgressId, isFinished) + var payload = await this.$db.updateLocalMediaProgressFinished({ localMediaProgressId, isFinished }) + console.log('toggleFinished payload', JSON.stringify(payload)) + if (!payload || payload.error) { + var errorMsg = payload ? payload.error : 'Unknown error' + this.$toast.error(errorMsg) + } else { + var localMediaProgress = payload.localMediaProgress + console.log('toggleFinished localMediaProgress', JSON.stringify(localMediaProgress)) + if (localMediaProgress) { + this.$store.commit('globals/updateLocalMediaProgress', localMediaProgress) + } + + var lmp = this.$store.getters['globals/getLocalMediaProgressById'](this.libraryItemId, this.episode.id) + console.log('toggleFinished Check LMP', this.libraryItemId, this.episode.id, JSON.stringify(lmp)) + + var serverUpdated = payload.server + if (serverUpdated) { + this.$toast.success(`Local & Server Item marked as ${isFinished ? 'Finished' : 'Not Finished'}`) + } else { + this.$toast.success(`Local Item marked as ${isFinished ? 'Finished' : 'Not Finished'}`) + } + } + this.isProcessingReadUpdate = false + } else { + var updatePayload = { + isFinished: !this.userIsFinished + } + this.$axios + .$patch(`/api/me/progress/${this.libraryItemId}/${this.episode.id}`, updatePayload) + .then(() => { + this.isProcessingReadUpdate = false + this.$toast.success(`Item marked as ${updatePayload.isFinished ? 'Finished' : 'Not Finished'}`) + }) + .catch((error) => { + console.error('Failed', error) + this.isProcessingReadUpdate = false + this.$toast.error(`Failed to mark as ${updatePayload.isFinished ? 'Finished' : 'Not Finished'}`) + }) + } } - }, - mounted() {} + } } \ No newline at end of file diff --git a/components/tables/podcast/EpisodesTable.vue b/components/tables/podcast/EpisodesTable.vue index 8b3516b7..e0a8e0c8 100644 --- a/components/tables/podcast/EpisodesTable.vue +++ b/components/tables/podcast/EpisodesTable.vue @@ -3,7 +3,7 @@

Episodes ({{ episodes.length }})

@@ -15,12 +15,29 @@ export default { episodes: { type: Array, default: () => [] - } + }, + localLibraryItemId: String, + localEpisodes: { + type: Array, + default: () => [] + }, + isLocal: Boolean // If is local then episodes and libraryItemId are local, otherwise local is passed in localLibraryItemId and localEpisodes }, data() { return {} }, - computed: {}, + computed: { + // Map of local episodes where server episode id is key + localEpisodeMap() { + var epmap = {} + this.localEpisodes.forEach((localEp) => { + if (localEp.serverEpisodeId) { + epmap[localEp.serverEpisodeId] = localEp + } + }) + return epmap + } + }, methods: {}, mounted() {} } diff --git a/components/widgets/DownloadProgressIndicator.vue b/components/widgets/DownloadProgressIndicator.vue index 30c2f0d2..9d4ddb08 100644 --- a/components/widgets/DownloadProgressIndicator.vue +++ b/components/widgets/DownloadProgressIndicator.vue @@ -71,7 +71,9 @@ export default { data.itemProgress = itemProgress data.episodes = downloadItemParts.filter((dip) => dip.episode).map((dip) => dip.episode) - console.log('Saving item update download payload', JSON.stringify(update)) + console.log('[download] Saving item update download payload', JSON.stringify(update)) + console.log('[download] Download Progress indicator data', JSON.stringify(data)) + this.$set(this.itemDownloadingMap, update.id, update) this.$store.commit('globals/addUpdateItemDownload', data) diff --git a/pages/item/_id.vue b/pages/item/_id.vue index 57b05aa8..a19183a2 100644 --- a/pages/item/_id.vue +++ b/pages/item/_id.vue @@ -58,7 +58,7 @@

{{ description }}

- +
@@ -120,6 +120,14 @@ export default { if (this.isLocal) return this.libraryItem return this.libraryItem.localLibraryItem || null }, + localLibraryItemId() { + return this.localLibraryItem ? this.localLibraryItem.id : null + }, + localLibraryItemEpisodes() { + if (!this.isPodcast || !this.localLibraryItem) return [] + var podcastMedia = this.localLibraryItem.media + return podcastMedia ? podcastMedia.episodes || [] : [] + }, isConnected() { return this.$store.state.socketConnected }, diff --git a/pages/localMedia/item/_id.vue b/pages/localMedia/item/_id.vue index 0ac39642..6336bfbe 100644 --- a/pages/localMedia/item/_id.vue +++ b/pages/localMedia/item/_id.vue @@ -1,6 +1,6 @@ - -
-

Episodes ({{ audioTracks.length }})

-