diff --git a/android/app/src/main/java/com/audiobookshelf/app/StorageManager.kt b/android/app/src/main/java/com/audiobookshelf/app/StorageManager.kt index 0f6d5cbc..7fa4d2b4 100644 --- a/android/app/src/main/java/com/audiobookshelf/app/StorageManager.kt +++ b/android/app/src/main/java/com/audiobookshelf/app/StorageManager.kt @@ -65,8 +65,9 @@ class StorageManager : Plugin() { var absolutePath = folder.getAbsolutePath(activity) var storageType = folder.getStorageType(activity) var simplePath = folder.getSimplePath(activity) + var folderId = android.util.Base64.encodeToString(folder.id.toByteArray(), android.util.Base64.DEFAULT) - var localFolder = LocalFolder(folder.id, folder.name, folder.uri.toString(),absolutePath, simplePath, storageType.toString(), mediaType) + var localFolder = LocalFolder(folderId, folder.name, folder.uri.toString(),absolutePath, simplePath, storageType.toString(), mediaType) DeviceManager.dbManager.saveLocalFolder(localFolder) call.resolve(JSObject(jacksonObjectMapper().writeValueAsString(localFolder))) @@ -127,13 +128,15 @@ class StorageManager : Plugin() { } @PluginMethod - fun searchFolder(call: PluginCall) { + fun scanFolder(call: PluginCall) { var folderId = call.data.getString("folderId", "").toString() - var folder: LocalFolder? = DeviceManager.dbManager.loadLocalFolder(folderId) + var forceAudioProbe = call.data.getBoolean("forceAudioProbe") + Log.d(TAG, "Scan Folder $folderId | Force Audio Probe $forceAudioProbe") + + var folder: LocalFolder? = DeviceManager.dbManager.getLocalFolder(folderId) folder?.let { - Log.d(TAG, "Searching folder ${it.contentUrl}") var folderScanner = FolderScanner(context) - var folderScanResult = folderScanner.scanForMediaItems(it.contentUrl, it.mediaType) + var folderScanResult = folderScanner.scanForMediaItems(it, forceAudioProbe) if (folderScanResult == null) { Log.d(TAG, "NO Scan DATA") return call.resolve(JSObject()) @@ -141,65 +144,15 @@ class StorageManager : Plugin() { Log.d(TAG, "Scan DATA ${jacksonObjectMapper().writeValueAsString(folderScanResult)}") return call.resolve(JSObject(jacksonObjectMapper().writeValueAsString(folderScanResult))) } - } - Log.d(TAG, "Folder not found $folderId") - call.resolve(JSObject()) - -// -// var df: DocumentFile? = DocumentFileCompat.fromUri(context, Uri.parse(folderUrl)) -// -// if (df == null) { -// Log.e(TAG, "Folder Doc File Invalid $folderUrl") -// var jsobj = JSObject() -// jsobj.put("folders", JSArray()) -// jsobj.put("files", JSArray()) -// call.resolve(jsobj) -// return -// } -// -// Log.d(TAG, "Folder as DF ${df.isDirectory} | ${df.getSimplePath(context)} | ${df.getBasePath(context)} | ${df.name}") -// -// var mediaFolders = mutableListOf() -// var foldersFound = df.search(false, DocumentFileType.FOLDER) -// -// foldersFound.forEach { -// Log.d(TAG, "Iterating over Folder Found ${it.name} | ${it.getSimplePath(context)} | URI: ${it.uri}") -// var folderName = it.name ?: "" -// var mediaFiles = mutableListOf() -// -// var filesInFolder = it.search(false, DocumentFileType.FILE, arrayOf("audio/*", "image/*")) -// filesInFolder.forEach { it2 -> -// var mimeType = it2?.mimeType ?: "" -// var filename = it2?.name ?: "" -// var isAudio = mimeType.startsWith("audio") -// Log.d(TAG, "Found $mimeType file $filename in folder $folderName") -// var imageFile = MediaFile(it2.uri, filename, it2.getSimplePath(context), it2.length(), mimeType, isAudio) -// mediaFiles.add(imageFile) -// } -// if (mediaFiles.size > 0) { -// mediaFolders.add(MediaFolder(it.uri, folderName, it.getSimplePath(context), mediaFiles)) -// } -// } -// -// // Files in root dir -// var rootMediaFiles = mutableListOf() -// var mediaFilesFound:List = df.search(false, DocumentFileType.FILE, arrayOf("audio/*", "image/*")) -// mediaFilesFound.forEach { -// Log.d(TAG, "Folder Root File Found ${it.name} | ${it.getSimplePath(context)} | URI: ${it.uri} | ${it.mimeType}") -// var mimeType = it?.mimeType ?: "" -// var filename = it?.name ?: "" -// var isAudio = mimeType.startsWith("audio") -// Log.d(TAG, "Found $mimeType file $filename in root folder") -// var imageFile = MediaFile(it.uri, filename, it.getSimplePath(context), it.length(), mimeType, isAudio) -// rootMediaFiles.add(imageFile) -// } -// -// var jsobj = JSObject() -// jsobj.put("folders", mediaFolders.map{ it.toJSObject() }) -// jsobj.put("files", rootMediaFiles.map{ it.toJSObject() }) -// call.resolve(jsobj) + } ?: call.resolve(JSObject()) } + @PluginMethod + fun removeFolder(call: PluginCall) { + var folderId = call.data.getString("folderId", "").toString() + DeviceManager.dbManager.removeLocalFolder(folderId) + call.resolve() + } @PluginMethod fun delete(call: PluginCall) { 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 fc8593f3..d871c107 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 @@ -151,5 +151,6 @@ data class AudioTrack( var contentUrl:String, var mimeType:String, var isLocal:Boolean, + var localFileId:String?, var audioProbeResult:AudioProbeResult? ) 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 fd04bf07..181320bc 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 @@ -8,6 +8,9 @@ import com.getcapacitor.PluginCall import com.getcapacitor.PluginMethod import com.getcapacitor.annotation.CapacitorPlugin import io.paperdb.Paper +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.launch import org.json.JSONObject @CapacitorPlugin(name = "DbManager") @@ -33,13 +36,26 @@ class DbManager : Plugin() { return localMediaItems } + fun getLocalMediaItemsInFolder(folderId:String):List { + var localMediaItems = loadLocalMediaItems() + return localMediaItems.filter { + it.folderId == folderId + } + } + fun loadLocalMediaItem(localMediaItemId:String):LocalMediaItem? { return Paper.book("localMediaItems").read(localMediaItemId) } + fun removeLocalMediaItem(localMediaItemId:String) { + Paper.book("localMediaItems").delete(localMediaItemId) + } + fun saveLocalMediaItems(localMediaItems:List) { - localMediaItems.map { - Paper.book("localMediaItems").write(it.id, it) + GlobalScope.launch(Dispatchers.IO) { + localMediaItems.map { + Paper.book("localMediaItems").write(it.id, it) + } } } @@ -47,7 +63,7 @@ class DbManager : Plugin() { Paper.book("localFolders").write(localFolder.id,localFolder) } - fun loadLocalFolder(folderId:String):LocalFolder? { + fun getLocalFolder(folderId:String):LocalFolder? { return Paper.book("localFolders").read(folderId) } @@ -62,6 +78,14 @@ class DbManager : Plugin() { return localFolders } + fun removeLocalFolder(folderId:String) { + var localMediaItems = getLocalMediaItemsInFolder(folderId) + localMediaItems.forEach { + Paper.book("localMediaItems").delete(it.id) + } + Paper.book("localFolders").delete(folderId) + } + fun saveObject(db:String, key:String, value:JSONObject) { Log.d(tag, "Saving Object $key ${value.toString()}") Paper.book(db).write(key, value) @@ -78,13 +102,16 @@ class DbManager : Plugin() { var db = call.getString("db", "").toString() var key = call.getString("key", "").toString() var value = call.getObject("value") - if (db == "" || key == "" || value == null) { - Log.d(tag, "saveFromWebview Invalid key/value") - } else { - var json = value as JSONObject - saveObject(db, key, json) + + GlobalScope.launch(Dispatchers.IO) { + if (db == "" || key == "" || value == null) { + Log.d(tag, "saveFromWebview Invalid key/value") + } else { + var json = value as JSONObject + saveObject(db, key, json) + } + call.resolve() } - call.resolve() } @PluginMethod @@ -102,24 +129,36 @@ class DbManager : Plugin() { } @PluginMethod - fun localFoldersFromWebView(call:PluginCall) { - var folders = getAllLocalFolders() - var folderObjArray = jacksonObjectMapper().writeValueAsString(folders) - var jsobj = JSObject() - jsobj.put("folders", folderObjArray) - call.resolve(jsobj) + fun getLocalFolders_WV(call:PluginCall) { + GlobalScope.launch(Dispatchers.IO) { + var folders = getAllLocalFolders() + var folderObjArray = jacksonObjectMapper().writeValueAsString(folders) + var jsobj = JSObject() + jsobj.put("folders", folderObjArray) + call.resolve(jsobj) + } } @PluginMethod - fun loadMediaItemsInFolder(call:PluginCall) { + fun getLocalFolder_WV(call:PluginCall) { var folderId = call.getString("folderId", "").toString() - var localMediaItems = loadLocalMediaItems().filter { - it.folderId == folderId + GlobalScope.launch(Dispatchers.IO) { + getLocalFolder(folderId)?.let { + var folderObj = jacksonObjectMapper().writeValueAsString(it) + call.resolve(JSObject(folderObj)) + } ?: call.resolve() } + } - var mediaItemsArray = jacksonObjectMapper().writeValueAsString(localMediaItems) - var jsobj = JSObject() - jsobj.put("localMediaItems", mediaItemsArray) - call.resolve(jsobj) + @PluginMethod + fun getLocalMediaItemsInFolder_WV(call:PluginCall) { + var folderId = call.getString("folderId", "").toString() + GlobalScope.launch(Dispatchers.IO) { + var localMediaItems = getLocalMediaItemsInFolder(folderId) + var mediaItemsArray = jacksonObjectMapper().writeValueAsString(localMediaItems) + var jsobj = JSObject() + jsobj.put("localMediaItems", mediaItemsArray) + call.resolve(jsobj) + } } } diff --git a/android/app/src/main/java/com/audiobookshelf/app/data/FolderScanResult.kt b/android/app/src/main/java/com/audiobookshelf/app/data/FolderScanResult.kt index 927f74cb..94f9e0a2 100644 --- a/android/app/src/main/java/com/audiobookshelf/app/data/FolderScanResult.kt +++ b/android/app/src/main/java/com/audiobookshelf/app/data/FolderScanResult.kt @@ -1,9 +1,10 @@ package com.audiobookshelf.app.data data class FolderScanResult( - val name:String?, - val absolutePath:String, - val mediaType:String, - val contentUrl:String, - val localMediaItems:MutableList, + var itemsAdded:Int, + var itemsUpdated:Int, + var itemsRemoved:Int, + var itemsUpToDate:Int, + val localFolder:LocalFolder, + val localMediaItems:List, ) 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 cb240568..0dad8609 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 @@ -1,6 +1,7 @@ package com.audiobookshelf.app.device import android.util.Log +import com.anggrayudi.storage.file.id import com.audiobookshelf.app.data.DbManager import com.audiobookshelf.app.data.DeviceData import com.audiobookshelf.app.data.ServerConfig @@ -17,4 +18,8 @@ object DeviceManager { init { Log.d(tag, "Device Manager Singleton invoked") } + + fun getBase64Id(id:String):String { + return android.util.Base64.encodeToString(id.toByteArray(), android.util.Base64.DEFAULT) + } } 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 e5ffea44..5aea2ff3 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 @@ -5,7 +5,9 @@ import android.net.Uri import android.util.Log import androidx.documentfile.provider.DocumentFile import com.anggrayudi.storage.file.* +import com.arthenica.ffmpegkit.FFmpegKitConfig import com.arthenica.ffmpegkit.FFprobeKit +import com.arthenica.ffmpegkit.Level import com.audiobookshelf.app.data.* import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper import com.fasterxml.jackson.module.kotlin.readValue @@ -13,25 +15,55 @@ import com.fasterxml.jackson.module.kotlin.readValue class FolderScanner(var ctx: Context) { private val tag = "FolderScanner" - fun scanForMediaItems(folderUrl: String, mediaType:String):FolderScanResult? { - var df: DocumentFile? = DocumentFileCompat.fromUri(ctx, Uri.parse(folderUrl)) + // TODO: CLEAN this monster! Divide into bite-size methods + fun scanForMediaItems(localFolder:LocalFolder, forceAudioProbe:Boolean):FolderScanResult? { + FFmpegKitConfig.enableLogCallback { log -> + if (log.level != Level.AV_LOG_STDERR) { // STDERR is filled with junk + Log.d(tag, "FFmpeg-Kit Log: (${log.level}) ${log.message}") + } + } + + var df: DocumentFile? = DocumentFileCompat.fromUri(ctx, Uri.parse(localFolder.contentUrl)) if (df == null) { - Log.e(tag, "Folder Doc File Invalid $folderUrl") + Log.e(tag, "Folder Doc File Invalid $localFolder.contentUrl") return null } - var folderName = df.name ?: "" - var folderPath = df.getAbsolutePath(ctx) - var folderUrl = df.uri.toString() - var folderId = df.id + var mediaItemsUpdated = 0 + var mediaItemsAdded = 0 + var mediaItemsRemoved = 0 + var mediaItemsUpToDate = 0 + + // Search for files in media item folder var foldersFound = df.search(false, DocumentFileType.FOLDER) + // Match folders found with media items already saved in db + var existingMediaItems = DeviceManager.dbManager.getLocalMediaItemsInFolder(localFolder.id) + + // Remove existing items no longer there + existingMediaItems = existingMediaItems.filter { lmi -> + var fileFound = foldersFound.find { f -> lmi.id == DeviceManager.getBase64Id(f.id) } + if (fileFound == null) { + Log.d(tag, "Existing media item is no longer in file system ${lmi.name}") + DeviceManager.dbManager.removeLocalMediaItem(lmi.id) + mediaItemsRemoved++ + } + fileFound != null + } + var mediaItems = mutableListOf() foldersFound.forEach { Log.d(tag, "Iterating over Folder Found ${it.name} | ${it.getSimplePath(ctx)} | URI: ${it.uri}") + var itemFolderName = it.name ?: "" + var itemId = DeviceManager.getBase64Id(it.id) + + var existingMediaItem = existingMediaItems.find { emi -> emi.id == itemId } + var existingLocalFiles = existingMediaItem?.localFiles ?: mutableListOf() + var existingAudioTracks = existingMediaItem?.audioTracks ?: mutableListOf() + var isNewOrUpdated = existingMediaItem == null var audioTracks = mutableListOf() var localFiles = mutableListOf() @@ -40,53 +72,119 @@ class FolderScanner(var ctx: Context) { var coverPath:String? = null var filesInFolder = it.search(false, DocumentFileType.FILE, arrayOf("audio/*", "image/*")) - filesInFolder.forEach { it2 -> - var mimeType = it2?.mimeType ?: "" - var filename = it2?.name ?: "" + + 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 ${existingMediaItem?.name}") + 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 localFile = LocalFile(it2.id,it2.name,it2.uri.toString(),it2.getAbsolutePath(ctx),it2.getSimplePath(ctx),it2.mimeType,it2.length()) + var localFileId = DeviceManager.getBase64Id(file.id) + + var localFile = LocalFile(localFileId,filename,file.uri.toString(),file.getAbsolutePath(ctx),file.getSimplePath(ctx),mimeType,file.length()) localFiles.add(localFile) - Log.d(tag, "File attributes Id:${it2.id}|ContentUrl:${localFile.contentUrl}|isDownloadsDocument:${it2.isDownloadsDocument}") + Log.d(tag, "File attributes Id:${localFileId}|ContentUrl:${localFile.contentUrl}|isDownloadsDocument:${file.isDownloadsDocument}") if (isAudio) { - Log.d(tag, "Scanning Audio File Path ${localFile.absolutePath}") + var audioTrackToAdd:AudioTrack? = null - // TODO: Make asynchronous - var session = FFprobeKit.execute("-i \"${localFile.absolutePath}\" -print_format json -show_format -show_streams -select_streams a -show_chapters -loglevel quiet") - var sessionData = session.output - Log.d(tag, "AFTER FFPROBE STRING $sessionData") + 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 + } + } - val mapper = jacksonObjectMapper() - val audioProbeResult = mapper.readValue(sessionData) - Log.d(tag, "Probe Result DATA ${audioProbeResult.duration} | ${audioProbeResult.size} | ${audioProbeResult.title} | ${audioProbeResult.artist}") + if (existingAudioTrack == null || forceAudioProbe) { + Log.d(tag, "Scanning Audio File Path ${localFile.absolutePath}") - var track = AudioTrack(index, startOffset, audioProbeResult.duration, filename, localFile.contentUrl, mimeType, true, audioProbeResult) - audioTracks.add(track) - startOffset += audioProbeResult.duration + // 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, true, localFileId, audioProbeResult) + audioTrackToAdd = track + } + + startOffset += audioProbeResult.duration + index++ + 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 (existingMediaItem != null && existingMediaItem.coverPath == null) { + // Existing media item did not have a cover - cover found on scan + isNewOrUpdated = true + } + // First image file use as cover path if (coverPath == null) { coverPath = localFile.absolutePath } } } - if (audioTracks.size > 0) { - Log.d(tag, "Found local media item named $itemFolderName with ${audioTracks.size} tracks") - var localMediaItem = LocalMediaItem(it.id, itemFolderName, mediaType, folderId, it.uri.toString(), it.getSimplePath(ctx), it.getAbsolutePath(ctx),audioTracks,localFiles,coverPath) + + if (existingMediaItem != null && audioTracks.isEmpty()) { + Log.d(tag, "Local media item ${existingMediaItem.name} no longer has audio tracks - removing item") + DeviceManager.dbManager.removeLocalMediaItem(existingMediaItem.id) + mediaItemsRemoved++ + } else if (existingMediaItem != null && !isNewOrUpdated) { + Log.d(tag, "Local media item ${existingMediaItem.name} has no updates") + mediaItemsUpToDate++ + } else if (audioTracks.isNotEmpty()) { + if (existingMediaItem != null) mediaItemsUpdated++ + else mediaItemsAdded++ + + Log.d(tag, "Found local media item named $itemFolderName with ${audioTracks.size} tracks and ${localFiles.size} local files") + var localMediaItem = LocalMediaItem(itemId, itemFolderName, localFolder.mediaType, localFolder.id, it.uri.toString(), it.getSimplePath(ctx), it.getAbsolutePath(ctx),audioTracks,localFiles,coverPath) mediaItems.add(localMediaItem) } } - return if (mediaItems.size > 0) { - Log.d(tag, "Found ${mediaItems.size} Media Items") + Log.d(tag, "Folder $${localFolder.name} scan Results: $mediaItemsAdded Added | $mediaItemsUpdated Updated | $mediaItemsRemoved Removed | $mediaItemsUpToDate Up-to-date") + + return if (mediaItems.isNotEmpty()) { DeviceManager.dbManager.saveLocalMediaItems(mediaItems) - FolderScanResult(folderName, folderPath, mediaType, folderUrl, mediaItems) + + var folderMediaItems = DeviceManager.dbManager.getLocalMediaItemsInFolder(localFolder.id) // Get all local media items + FolderScanResult(mediaItemsAdded, mediaItemsUpdated, mediaItemsRemoved, mediaItemsUpToDate, localFolder, folderMediaItems) } else { - Log.d(tag, "No Media Items Found") - null + Log.d(tag, "No Media Items to save") + FolderScanResult(mediaItemsAdded, mediaItemsUpdated, mediaItemsRemoved, mediaItemsUpToDate, localFolder, mutableListOf()) } } } diff --git a/components/ui/Checkbox.vue b/components/ui/Checkbox.vue new file mode 100644 index 00000000..f2717041 --- /dev/null +++ b/components/ui/Checkbox.vue @@ -0,0 +1,71 @@ + + + \ No newline at end of file diff --git a/components/ui/Dropdown.vue b/components/ui/Dropdown.vue index c351ab42..431c9f4a 100644 --- a/components/ui/Dropdown.vue +++ b/components/ui/Dropdown.vue @@ -3,7 +3,7 @@

{{ label }}