Media folder management page, android media folder scanner

This commit is contained in:
advplyr 2022-04-01 18:33:40 -05:00
parent a259883979
commit 94b9dbb8b3
12 changed files with 456 additions and 339 deletions

View file

@ -65,8 +65,9 @@ class StorageManager : Plugin() {
var absolutePath = folder.getAbsolutePath(activity) var absolutePath = folder.getAbsolutePath(activity)
var storageType = folder.getStorageType(activity) var storageType = folder.getStorageType(activity)
var simplePath = folder.getSimplePath(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) DeviceManager.dbManager.saveLocalFolder(localFolder)
call.resolve(JSObject(jacksonObjectMapper().writeValueAsString(localFolder))) call.resolve(JSObject(jacksonObjectMapper().writeValueAsString(localFolder)))
@ -127,13 +128,15 @@ class StorageManager : Plugin() {
} }
@PluginMethod @PluginMethod
fun searchFolder(call: PluginCall) { fun scanFolder(call: PluginCall) {
var folderId = call.data.getString("folderId", "").toString() 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 { folder?.let {
Log.d(TAG, "Searching folder ${it.contentUrl}")
var folderScanner = FolderScanner(context) var folderScanner = FolderScanner(context)
var folderScanResult = folderScanner.scanForMediaItems(it.contentUrl, it.mediaType) var folderScanResult = folderScanner.scanForMediaItems(it, forceAudioProbe)
if (folderScanResult == null) { if (folderScanResult == null) {
Log.d(TAG, "NO Scan DATA") Log.d(TAG, "NO Scan DATA")
return call.resolve(JSObject()) return call.resolve(JSObject())
@ -141,65 +144,15 @@ class StorageManager : Plugin() {
Log.d(TAG, "Scan DATA ${jacksonObjectMapper().writeValueAsString(folderScanResult)}") Log.d(TAG, "Scan DATA ${jacksonObjectMapper().writeValueAsString(folderScanResult)}")
return call.resolve(JSObject(jacksonObjectMapper().writeValueAsString(folderScanResult))) return call.resolve(JSObject(jacksonObjectMapper().writeValueAsString(folderScanResult)))
} }
} } ?: call.resolve(JSObject())
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<MediaFolder>()
// 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<MediaFile>()
//
// 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<MediaFile>()
// var mediaFilesFound:List<DocumentFile> = 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)
} }
@PluginMethod
fun removeFolder(call: PluginCall) {
var folderId = call.data.getString("folderId", "").toString()
DeviceManager.dbManager.removeLocalFolder(folderId)
call.resolve()
}
@PluginMethod @PluginMethod
fun delete(call: PluginCall) { fun delete(call: PluginCall) {

View file

@ -151,5 +151,6 @@ data class AudioTrack(
var contentUrl:String, var contentUrl:String,
var mimeType:String, var mimeType:String,
var isLocal:Boolean, var isLocal:Boolean,
var localFileId:String?,
var audioProbeResult:AudioProbeResult? var audioProbeResult:AudioProbeResult?
) )

View file

@ -8,6 +8,9 @@ import com.getcapacitor.PluginCall
import com.getcapacitor.PluginMethod import com.getcapacitor.PluginMethod
import com.getcapacitor.annotation.CapacitorPlugin import com.getcapacitor.annotation.CapacitorPlugin
import io.paperdb.Paper import io.paperdb.Paper
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
import org.json.JSONObject import org.json.JSONObject
@CapacitorPlugin(name = "DbManager") @CapacitorPlugin(name = "DbManager")
@ -33,21 +36,34 @@ class DbManager : Plugin() {
return localMediaItems return localMediaItems
} }
fun getLocalMediaItemsInFolder(folderId:String):List<LocalMediaItem> {
var localMediaItems = loadLocalMediaItems()
return localMediaItems.filter {
it.folderId == folderId
}
}
fun loadLocalMediaItem(localMediaItemId:String):LocalMediaItem? { fun loadLocalMediaItem(localMediaItemId:String):LocalMediaItem? {
return Paper.book("localMediaItems").read(localMediaItemId) return Paper.book("localMediaItems").read(localMediaItemId)
} }
fun removeLocalMediaItem(localMediaItemId:String) {
Paper.book("localMediaItems").delete(localMediaItemId)
}
fun saveLocalMediaItems(localMediaItems:List<LocalMediaItem>) { fun saveLocalMediaItems(localMediaItems:List<LocalMediaItem>) {
GlobalScope.launch(Dispatchers.IO) {
localMediaItems.map { localMediaItems.map {
Paper.book("localMediaItems").write(it.id, it) Paper.book("localMediaItems").write(it.id, it)
} }
} }
}
fun saveLocalFolder(localFolder:LocalFolder) { fun saveLocalFolder(localFolder:LocalFolder) {
Paper.book("localFolders").write(localFolder.id,localFolder) Paper.book("localFolders").write(localFolder.id,localFolder)
} }
fun loadLocalFolder(folderId:String):LocalFolder? { fun getLocalFolder(folderId:String):LocalFolder? {
return Paper.book("localFolders").read(folderId) return Paper.book("localFolders").read(folderId)
} }
@ -62,6 +78,14 @@ class DbManager : Plugin() {
return localFolders 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) { fun saveObject(db:String, key:String, value:JSONObject) {
Log.d(tag, "Saving Object $key ${value.toString()}") Log.d(tag, "Saving Object $key ${value.toString()}")
Paper.book(db).write(key, value) Paper.book(db).write(key, value)
@ -78,6 +102,8 @@ class DbManager : Plugin() {
var db = call.getString("db", "").toString() var db = call.getString("db", "").toString()
var key = call.getString("key", "").toString() var key = call.getString("key", "").toString()
var value = call.getObject("value") var value = call.getObject("value")
GlobalScope.launch(Dispatchers.IO) {
if (db == "" || key == "" || value == null) { if (db == "" || key == "" || value == null) {
Log.d(tag, "saveFromWebview Invalid key/value") Log.d(tag, "saveFromWebview Invalid key/value")
} else { } else {
@ -86,6 +112,7 @@ class DbManager : Plugin() {
} }
call.resolve() call.resolve()
} }
}
@PluginMethod @PluginMethod
fun loadFromWebview(call:PluginCall) { fun loadFromWebview(call:PluginCall) {
@ -102,24 +129,36 @@ class DbManager : Plugin() {
} }
@PluginMethod @PluginMethod
fun localFoldersFromWebView(call:PluginCall) { fun getLocalFolders_WV(call:PluginCall) {
GlobalScope.launch(Dispatchers.IO) {
var folders = getAllLocalFolders() var folders = getAllLocalFolders()
var folderObjArray = jacksonObjectMapper().writeValueAsString(folders) var folderObjArray = jacksonObjectMapper().writeValueAsString(folders)
var jsobj = JSObject() var jsobj = JSObject()
jsobj.put("folders", folderObjArray) jsobj.put("folders", folderObjArray)
call.resolve(jsobj) call.resolve(jsobj)
} }
@PluginMethod
fun loadMediaItemsInFolder(call:PluginCall) {
var folderId = call.getString("folderId", "").toString()
var localMediaItems = loadLocalMediaItems().filter {
it.folderId == folderId
} }
@PluginMethod
fun getLocalFolder_WV(call:PluginCall) {
var folderId = call.getString("folderId", "").toString()
GlobalScope.launch(Dispatchers.IO) {
getLocalFolder(folderId)?.let {
var folderObj = jacksonObjectMapper().writeValueAsString(it)
call.resolve(JSObject(folderObj))
} ?: call.resolve()
}
}
@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 mediaItemsArray = jacksonObjectMapper().writeValueAsString(localMediaItems)
var jsobj = JSObject() var jsobj = JSObject()
jsobj.put("localMediaItems", mediaItemsArray) jsobj.put("localMediaItems", mediaItemsArray)
call.resolve(jsobj) call.resolve(jsobj)
} }
}
} }

View file

@ -1,9 +1,10 @@
package com.audiobookshelf.app.data package com.audiobookshelf.app.data
data class FolderScanResult( data class FolderScanResult(
val name:String?, var itemsAdded:Int,
val absolutePath:String, var itemsUpdated:Int,
val mediaType:String, var itemsRemoved:Int,
val contentUrl:String, var itemsUpToDate:Int,
val localMediaItems:MutableList<LocalMediaItem>, val localFolder:LocalFolder,
val localMediaItems:List<LocalMediaItem>,
) )

View file

@ -1,6 +1,7 @@
package com.audiobookshelf.app.device package com.audiobookshelf.app.device
import android.util.Log import android.util.Log
import com.anggrayudi.storage.file.id
import com.audiobookshelf.app.data.DbManager import com.audiobookshelf.app.data.DbManager
import com.audiobookshelf.app.data.DeviceData import com.audiobookshelf.app.data.DeviceData
import com.audiobookshelf.app.data.ServerConfig import com.audiobookshelf.app.data.ServerConfig
@ -17,4 +18,8 @@ object DeviceManager {
init { init {
Log.d(tag, "Device Manager Singleton invoked") Log.d(tag, "Device Manager Singleton invoked")
} }
fun getBase64Id(id:String):String {
return android.util.Base64.encodeToString(id.toByteArray(), android.util.Base64.DEFAULT)
}
} }

View file

@ -5,7 +5,9 @@ import android.net.Uri
import android.util.Log import android.util.Log
import androidx.documentfile.provider.DocumentFile import androidx.documentfile.provider.DocumentFile
import com.anggrayudi.storage.file.* import com.anggrayudi.storage.file.*
import com.arthenica.ffmpegkit.FFmpegKitConfig
import com.arthenica.ffmpegkit.FFprobeKit import com.arthenica.ffmpegkit.FFprobeKit
import com.arthenica.ffmpegkit.Level
import com.audiobookshelf.app.data.* import com.audiobookshelf.app.data.*
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.fasterxml.jackson.module.kotlin.readValue import com.fasterxml.jackson.module.kotlin.readValue
@ -13,25 +15,55 @@ import com.fasterxml.jackson.module.kotlin.readValue
class FolderScanner(var ctx: Context) { class FolderScanner(var ctx: Context) {
private val tag = "FolderScanner" private val tag = "FolderScanner"
fun scanForMediaItems(folderUrl: String, mediaType:String):FolderScanResult? { // TODO: CLEAN this monster! Divide into bite-size methods
var df: DocumentFile? = DocumentFileCompat.fromUri(ctx, Uri.parse(folderUrl)) 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) { if (df == null) {
Log.e(tag, "Folder Doc File Invalid $folderUrl") Log.e(tag, "Folder Doc File Invalid $localFolder.contentUrl")
return null 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) 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<LocalMediaItem>() var mediaItems = mutableListOf<LocalMediaItem>()
foldersFound.forEach { foldersFound.forEach {
Log.d(tag, "Iterating over Folder Found ${it.name} | ${it.getSimplePath(ctx)} | URI: ${it.uri}") Log.d(tag, "Iterating over Folder Found ${it.name} | ${it.getSimplePath(ctx)} | URI: ${it.uri}")
var itemFolderName = it.name ?: "" 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<AudioTrack>() var audioTracks = mutableListOf<AudioTrack>()
var localFiles = mutableListOf<LocalFile>() var localFiles = mutableListOf<LocalFile>()
@ -40,53 +72,119 @@ class FolderScanner(var ctx: Context) {
var coverPath:String? = null var coverPath:String? = null
var filesInFolder = it.search(false, DocumentFileType.FILE, arrayOf("audio/*", "image/*")) var filesInFolder = it.search(false, DocumentFileType.FILE, arrayOf("audio/*", "image/*"))
filesInFolder.forEach { it2 ->
var mimeType = it2?.mimeType ?: "" var existingLocalFilesRemoved = existingLocalFiles.filter { elf ->
var filename = it2?.name ?: "" 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") var isAudio = mimeType.startsWith("audio")
Log.d(tag, "Found $mimeType file $filename in folder $itemFolderName") 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) 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) { 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}") Log.d(tag, "Scanning Audio File Path ${localFile.absolutePath}")
// TODO: Make asynchronous // 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 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")
val mapper = jacksonObjectMapper() val audioProbeResult = jacksonObjectMapper().readValue<AudioProbeResult>(session.output)
val audioProbeResult = mapper.readValue<AudioProbeResult>(sessionData)
Log.d(tag, "Probe Result DATA ${audioProbeResult.duration} | ${audioProbeResult.size} | ${audioProbeResult.title} | ${audioProbeResult.artist}") Log.d(tag, "Probe Result DATA ${audioProbeResult.duration} | ${audioProbeResult.size} | ${audioProbeResult.title} | ${audioProbeResult.artist}")
var track = AudioTrack(index, startOffset, audioProbeResult.duration, filename, localFile.contentUrl, mimeType, true, audioProbeResult) if (existingAudioTrack != null) {
audioTracks.add(track) // Update audio probe data on existing audio track
startOffset += audioProbeResult.duration existingAudioTrack.audioProbeResult = audioProbeResult
audioTrackToAdd = existingAudioTrack
} else { } 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 // First image file use as cover path
if (coverPath == null) { if (coverPath == null) {
coverPath = localFile.absolutePath coverPath = localFile.absolutePath
} }
} }
} }
if (audioTracks.size > 0) {
Log.d(tag, "Found local media item named $itemFolderName with ${audioTracks.size} tracks") if (existingMediaItem != null && audioTracks.isEmpty()) {
var localMediaItem = LocalMediaItem(it.id, itemFolderName, mediaType, folderId, it.uri.toString(), it.getSimplePath(ctx), it.getAbsolutePath(ctx),audioTracks,localFiles,coverPath) 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) mediaItems.add(localMediaItem)
} }
} }
return if (mediaItems.size > 0) { Log.d(tag, "Folder $${localFolder.name} scan Results: $mediaItemsAdded Added | $mediaItemsUpdated Updated | $mediaItemsRemoved Removed | $mediaItemsUpToDate Up-to-date")
Log.d(tag, "Found ${mediaItems.size} Media Items")
return if (mediaItems.isNotEmpty()) {
DeviceManager.dbManager.saveLocalMediaItems(mediaItems) 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 { } else {
Log.d(tag, "No Media Items Found") Log.d(tag, "No Media Items to save")
null FolderScanResult(mediaItemsAdded, mediaItemsUpdated, mediaItemsRemoved, mediaItemsUpToDate, localFolder, mutableListOf())
} }
} }
} }

View file

@ -0,0 +1,71 @@
<template>
<label class="flex justify-start items-center" :class="!disabled ? 'cursor-pointer' : ''">
<div class="border-2 rounded flex flex-shrink-0 justify-center items-center" :class="wrapperClass">
<input v-model="selected" :disabled="disabled" type="checkbox" class="opacity-0 absolute" :class="!disabled ? 'cursor-pointer' : ''" />
<svg v-if="selected" class="fill-current pointer-events-none" :class="svgClass" viewBox="0 0 20 20"><path d="M0 11l2-2 5 5L18 3l2 2L7 18z" /></svg>
</div>
<div v-if="label" class="select-none text-gray-100" :class="labelClassname">{{ label }}</div>
</label>
</template>
<script>
export default {
props: {
value: Boolean,
label: String,
small: Boolean,
checkboxBg: {
type: String,
default: 'white'
},
borderColor: {
type: String,
default: 'gray-400'
},
checkColor: {
type: String,
default: 'green-500'
},
labelClass: {
type: String,
default: ''
},
disabled: Boolean
},
data() {
return {}
},
computed: {
selected: {
get() {
return this.value
},
set(val) {
this.$emit('input', !!val)
}
},
wrapperClass() {
var classes = [`bg-${this.checkboxBg} border-${this.borderColor}`]
if (this.small) classes.push('w-4 h-4')
else classes.push('w-6 h-6')
return classes.join(' ')
},
labelClassname() {
if (this.labelClass) return this.labelClass
var classes = ['pl-1']
if (this.small) classes.push('text-xs md:text-sm')
return classes.join(' ')
},
svgClass() {
var classes = [`text-${this.checkColor}`]
if (this.small) classes.push('w-3 h-3')
else classes.push('w-4 h-4')
return classes.join(' ')
}
},
methods: {},
mounted() {}
}
</script>

View file

@ -3,7 +3,7 @@
<p class="text-sm font-semibold" :class="disabled ? 'text-gray-300' : ''">{{ label }}</p> <p class="text-sm font-semibold" :class="disabled ? 'text-gray-300' : ''">{{ label }}</p>
<button type="button" :disabled="disabled" class="relative w-full border rounded shadow-sm pl-3 pr-8 py-2 text-left focus:outline-none sm:text-sm" :class="buttonClass" aria-haspopup="listbox" aria-expanded="true" @click.stop.prevent="clickShowMenu"> <button type="button" :disabled="disabled" class="relative w-full border rounded shadow-sm pl-3 pr-8 py-2 text-left focus:outline-none sm:text-sm" :class="buttonClass" aria-haspopup="listbox" aria-expanded="true" @click.stop.prevent="clickShowMenu">
<span class="flex items-center"> <span class="flex items-center">
<span class="block truncate" :class="small ? 'text-sm' : ''">{{ selectedText }}</span> <span class="block truncate" :class="small ? 'text-sm' : ''">{{ selectedText || placeholder || '' }}</span>
</span> </span>
<span class="ml-3 absolute inset-y-0 right-0 flex items-center pr-2 pointer-events-none"> <span class="ml-3 absolute inset-y-0 right-0 flex items-center pr-2 pointer-events-none">
<span class="material-icons">expand_more</span> <span class="material-icons">expand_more</span>
@ -37,7 +37,8 @@ export default {
default: () => [] default: () => []
}, },
disabled: Boolean, disabled: Boolean,
small: Boolean small: Boolean,
placeholder: String
}, },
data() { data() {
return { return {

80
components/ui/IconBtn.vue Normal file
View file

@ -0,0 +1,80 @@
<template>
<button class="icon-btn rounded-md flex items-center justify-center h-9 w-9 relative" @mousedown.prevent :disabled="disabled || loading" :class="className" @click="clickBtn">
<div v-if="loading" class="text-white absolute top-0 left-0 w-full h-full flex items-center justify-center text-opacity-100">
<svg class="animate-spin" style="width: 24px; height: 24px" viewBox="0 0 24 24">
<path fill="currentColor" d="M12,4V2A10,10 0 0,0 2,12H4A8,8 0 0,1 12,4Z" />
</svg>
</div>
<span v-else :class="outlined ? 'material-icons-outlined' : 'material-icons'" :style="{ fontSize }">{{ icon }}</span>
</button>
</template>
<script>
export default {
props: {
icon: String,
disabled: Boolean,
bgColor: {
type: String,
default: 'primary'
},
outlined: Boolean,
borderless: Boolean,
loading: Boolean
},
data() {
return {}
},
computed: {
className() {
var classes = []
if (!this.borderless) {
classes.push(`bg-${this.bgColor} border border-gray-600`)
}
return classes.join(' ')
},
fontSize() {
if (this.icon === 'edit') return '1.25rem'
return '1.4rem'
}
},
methods: {
clickBtn(e) {
if (this.disabled || this.loading) {
e.preventDefault()
return
}
e.preventDefault()
this.$emit('click')
e.stopPropagation()
}
},
mounted() {}
}
</script>
<style>
button.icon-btn:disabled {
cursor: not-allowed;
}
button.icon-btn::before {
content: '';
position: absolute;
border-radius: 6px;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(255, 255, 255, 0);
transition: all 0.1s ease-in-out;
}
button.icon-btn:hover:not(:disabled)::before {
background-color: rgba(255, 255, 255, 0.1);
}
button.icon-btn:disabled::before {
background-color: rgba(0, 0, 0, 0.2);
}
button.icon-btn:disabled span {
color: #777;
}
</style>

View file

@ -1,10 +1,12 @@
<template> <template>
<div class="w-full h-full py-6 px-2"> <div class="w-full h-full py-6 px-2">
<div class="flex justify-between mb-4"> <div class="flex items-center mb-4">
<ui-btn to="/localMedia/folders">Back</ui-btn> <div class="flex-grow" />
<ui-btn :loading="isScanning" @click="searchFolder">Scan</ui-btn> <ui-btn v-if="!removingFolder" :loading="isScanning" small @click="clickScan">Scan</ui-btn>
<ui-btn v-if="!removingFolder && localMediaItems.length" :loading="isScanning" small class="ml-2" color="warning" @click="clickForceRescan">Force Re-Scan</ui-btn>
<ui-icon-btn class="ml-2" bg-color="error" outlined :loading="removingFolder" icon="delete" @click="clickDeleteFolder" />
</div> </div>
<p class="text-lg mb-0.5 text-white text-opacity-75">Folder: {{ folderId }}</p> <p class="text-lg mb-0.5 text-white text-opacity-75">Folder: {{ folderName }}</p>
<p class="mb-4 text-xl">Local Media Items ({{ localMediaItems.length }})</p> <p class="mb-4 text-xl">Local Media Items ({{ localMediaItems.length }})</p>
<div v-if="isScanning" class="w-full text-center p-4"> <div v-if="isScanning" class="w-full text-center p-4">
<p>Scanning...</p> <p>Scanning...</p>
@ -32,6 +34,7 @@
<script> <script>
import { Capacitor } from '@capacitor/core' import { Capacitor } from '@capacitor/core'
import { Dialog } from '@capacitor/dialog'
import StorageManager from '@/plugins/storage-manager' import StorageManager from '@/plugins/storage-manager'
export default { export default {
@ -44,19 +47,60 @@ export default {
data() { data() {
return { return {
localMediaItems: [], localMediaItems: [],
isScanning: false folder: null,
isScanning: false,
removingFolder: false
}
},
computed: {
folderName() {
return this.folder ? this.folder.name : null
} }
}, },
computed: {},
methods: { methods: {
clickScan() {
this.scanFolder()
},
clickForceRescan() {
this.scanFolder(true)
},
async clickDeleteFolder() {
var deleteMessage = 'Are you sure you want to remove this folder? (does not delete anything in your file system)'
if (this.localMediaItems.length) {
deleteMessage = `Are you sure you want to remove this folder and ${this.localMediaItems.length} media items? (does not delete anything in your file system)`
}
const { value } = await Dialog.confirm({
title: 'Confirm',
message: deleteMessage
})
if (value) {
this.removingFolder = true
await StorageManager.removeFolder({ folderId: this.folderId })
this.removingFolder = false
this.$router.replace('/localMedia/folders')
}
},
play(mediaItem) { play(mediaItem) {
this.$eventBus.$emit('play-local-item', mediaItem.id) this.$eventBus.$emit('play-local-item', mediaItem.id)
}, },
async searchFolder() { async scanFolder(forceAudioProbe = false) {
this.isScanning = true this.isScanning = true
var response = await StorageManager.searchFolder({ folderId: this.folderId }) var response = await StorageManager.scanFolder({ folderId: this.folderId, forceAudioProbe })
if (response && response.localMediaItems) { if (response && response.localMediaItems) {
var itemsAdded = response.itemsAdded
var itemsUpdated = response.itemsUpdated
var itemsRemoved = response.itemsRemoved
var itemsUpToDate = response.itemsUpToDate
var toastMessages = []
if (itemsAdded) toastMessages.push(`${itemsAdded} Added`)
if (itemsUpdated) toastMessages.push(`${itemsUpdated} Updated`)
if (itemsRemoved) toastMessages.push(`${itemsRemoved} Removed`)
if (itemsUpToDate) toastMessages.push(`${itemsUpToDate} Up-to-date`)
this.$toast.info(`Folder scan complete:\n${toastMessages.join(' | ')}`)
// When all items are up-to-date then local media items are not returned
if (response.localMediaItems.length) {
this.localMediaItems = response.localMediaItems.map((mi) => { this.localMediaItems = response.localMediaItems.map((mi) => {
if (mi.coverPath) { if (mi.coverPath) {
mi.coverPathSrc = Capacitor.convertFileSrc(mi.coverPath) mi.coverPathSrc = Capacitor.convertFileSrc(mi.coverPath)
@ -64,14 +108,17 @@ export default {
return mi return mi
}) })
console.log('Set Local Media Items', this.localMediaItems.length) console.log('Set Local Media Items', this.localMediaItems.length)
}
} else { } else {
console.log('No Local media items found') console.log('No Local media items found')
} }
this.isScanning = false this.isScanning = false
}, },
async init() { async init() {
var items = (await this.$db.loadLocalMediaItemsInFolder(this.folderId)) || [] var folder = await this.$db.getLocalFolder(this.folderId)
this.folder = folder
var items = (await this.$db.getLocalMediaItemsInFolder(this.folderId)) || []
console.log('Init folder', this.folderId, items) console.log('Init folder', this.folderId, items)
this.localMediaItems = items.map((lmi) => { this.localMediaItems = items.map((lmi) => {
return { return {
@ -80,7 +127,7 @@ export default {
} }
}) })
if (this.shouldScan) { if (this.shouldScan) {
this.searchFolder() this.scanFolder()
} }
} }
}, },

View file

@ -1,15 +1,15 @@
<template> <template>
<div class="w-full h-full py-6"> <div class="w-full h-full py-6">
<h1 class="text-2xl px-4">Downloads</h1> <h1 class="text-2xl px-4 mb-2">Local Folders</h1>
<div v-if="!isIos" class="w-full max-w-full px-2 py-2"> <div v-if="!isIos" class="w-full max-w-full px-2 py-2">
<template v-for="folder in localFolders"> <template v-for="folder in localFolders">
<nuxt-link :to="`/localMedia/folders/${folder.id}`" :key="folder.id" class="flex items-center px-2 py-4 bg-primary rounded-md border-bg mb-1"> <nuxt-link :to="`/localMedia/folders/${folder.id}`" :key="folder.id" class="flex items-center px-2 py-4 bg-primary rounded-md border-bg mb-1">
<span class="material-icons text-xl text-yellow-400">folder</span> <span class="material-icons text-xl text-yellow-400">folder</span>
<p class="ml-2">{{ folder.id }}</p> <p class="ml-2">{{ folder.name }}</p>
<div class="flex-grow" /> <div class="flex-grow" />
<p class="text-sm italic text-gray-300 px-2 capitalize">{{ folder.mediaType }}s</p> <p class="text-sm italic text-gray-300 px-3 capitalize">{{ folder.mediaType }}s</p>
<span class="material-icons text-base text-gray-300">arrow_right</span> <span class="material-icons text-xl text-gray-300">arrow_right</span>
</nuxt-link> </nuxt-link>
</template> </template>
<div v-if="!localFolders.length" class="flex justify-center"> <div v-if="!localFolders.length" class="flex justify-center">
@ -17,84 +17,22 @@
</div> </div>
<div class="flex p-2 border-t border-primary mt-2"> <div class="flex p-2 border-t border-primary mt-2">
<div class="flex-grow pr-1"> <div class="flex-grow pr-1">
<ui-dropdown v-model="newFolderMediaType" :items="mediaTypeItems" /> <ui-dropdown v-model="newFolderMediaType" placeholder="Select media type" :items="mediaTypeItems" />
</div> </div>
<ui-btn small class="w-28" @click="selectFolder">Add Folder</ui-btn> <ui-btn small class="w-28" color="success" @click="selectFolder">New Folder</ui-btn>
</div> </div>
</div> </div>
<!-- <div v-if="!isIos" class="list-content-body relative w-full overflow-x-hidden overflow-y-auto bg-primary">
<template v-if="showingDownloads">
<div v-if="!totalDownloads" class="flex items-center justify-center h-40">
<p>No Downloads</p>
</div>
<ul v-else class="h-full w-full" role="listbox" aria-labelledby="listbox-label">
<li v-for="download in downloadsDownloading" :key="download.id" class="text-gray-400 select-none relative px-4 py-5 border-b border-white border-opacity-10 bg-black bg-opacity-10">
<div class="flex items-center justify-center">
<div class="w-3/4">
<span class="text-xs">({{ downloadingProgress[download.id] || 0 }}%) {{ download.isPreparing ? 'Preparing' : 'Downloading' }}...</span>
<p class="font-normal truncate text-sm">{{ download.audiobook.book.title }}</p>
</div>
<div class="flex-grow" />
<div class="shadow-sm text-white flex items-center justify-center rounded-full animate-spin">
<span class="material-icons">refresh</span>
</div>
</div>
</li>
<li v-for="download in downloadsReady" :key="download.id" class="text-gray-50 select-none relative pr-4 pl-2 py-5 border-b border-white border-opacity-10" @click="jumpToAudiobook(download)">
<modals-downloads-download-item :download="download" @play="playDownload" @delete="clickDeleteDownload" />
</li>
</ul>
</template>
<template v-else>
<div class="w-full h-full">
<div class="w-full flex justify-around py-4 px-2">
<ui-btn small color="error" @click="resetFolder">Reset</ui-btn>
</div>
<p v-if="isScanning" class="text-center my-8">Scanning Folder..</p>
<p v-else-if="!mediaScanResults" class="text-center my-8">No Files Found</p>
<div v-else>
<div v-for="mediaFolder in mediaScanResults.folders" :key="mediaFolder.uri" class="w-full px-2 py-2">
<div class="flex items-center">
<span class="material-icons text-base text-white text-opacity-50">folder</span>
<p class="ml-1 py-0.5">{{ mediaFolder.name }}</p>
</div>
<div v-for="mediaFile in mediaFolder.files" :key="mediaFile.uri" class="ml-3 flex items-center">
<span class="material-icons text-base text-white text-opacity-50">{{ mediaFile.isAudio ? 'music_note' : 'image' }}</span>
<p class="ml-1 py-0.5">{{ mediaFile.name }}</p>
</div>
</div>
<div v-for="mediaFile in mediaScanResults.files" :key="mediaFile.uri" class="w-full px-2 py-2">
<div class="flex items-center">
<span class="material-icons text-base text-white text-opacity-50">{{ mediaFile.isAudio ? 'music_note' : 'image' }}</span>
<p class="ml-1 py-0.5">{{ mediaFile.name }}</p>
</div>
</div>
</div>
</div>
</template>
</div> -->
</div> </div>
</template> </template>
<script> <script>
import { Capacitor } from '@capacitor/core'
import { Dialog } from '@capacitor/dialog'
import AudioDownloader from '@/plugins/audio-downloader'
import StorageManager from '@/plugins/storage-manager' import StorageManager from '@/plugins/storage-manager'
export default { export default {
data() { data() {
return { return {
downloadingProgress: {},
totalSize: 0,
showingDownloads: true,
isScanning: false,
localMediaItems: [],
localFolders: [], localFolders: [],
newFolderMediaType: 'book', newFolderMediaType: null,
mediaTypeItems: [ mediaTypeItems: [
{ {
value: 'book', value: 'book',
@ -113,37 +51,13 @@ export default {
}, },
isSocketConnected() { isSocketConnected() {
return this.$store.state.socketConnected return this.$store.state.socketConnected
},
hasStoragePermission() {
return this.$store.state.hasStoragePermission
},
downloadFolder() {
return this.$store.state.downloadFolder
},
downloadFolderSimplePath() {
return this.downloadFolder ? this.downloadFolder.simplePath : null
},
downloadFolderUri() {
return this.downloadFolder ? this.downloadFolder.uri : null
},
totalDownloads() {
return this.downloadsReady.length + this.downloadsDownloading.length
},
downloadsDownloading() {
return this.downloads.filter((d) => d.isDownloading || d.isPreparing)
},
downloadsReady() {
return this.downloads.filter((d) => !d.isDownloading && !d.isPreparing)
},
downloads() {
return this.$store.state.downloads.downloads
},
mediaScanResults() {
return this.$store.state.downloads.mediaScanResults
} }
}, },
methods: { methods: {
async selectFolder() { async selectFolder() {
if (!this.newFolderMediaType) {
return this.$toast.warn('Must select a media type')
}
var folderObj = await StorageManager.selectFolder({ mediaType: this.newFolderMediaType }) var folderObj = await StorageManager.selectFolder({ mediaType: this.newFolderMediaType })
if (folderObj.error) { if (folderObj.error) {
return this.$toast.error(`Error: ${folderObj.error || 'Unknown Error'}`) return this.$toast.error(`Error: ${folderObj.error || 'Unknown Error'}`)
@ -165,114 +79,14 @@ export default {
this.$toast.success('Folder permission success') this.$toast.success('Folder permission success')
} }
// await this.searchFolder(folderObj.id)
this.$router.push(`/localMedia/folders/${folderObj.id}?scan=1`) this.$router.push(`/localMedia/folders/${folderObj.id}?scan=1`)
}, },
async changeDownloadFolderClick() {
if (!this.hasStoragePermission) {
StorageManager.requestStoragePermission()
} else {
var folderObj = await StorageManager.selectFolder({ mediaType: 'book' })
if (folderObj.error) {
return this.$toast.error(`Error: ${folderObj.error || 'Unknown Error'}`)
}
var indexOfExisting = this.localFolders.findIndex((lf) => lf.id == folderObj.id)
if (indexOfExisting >= 0) {
this.localFolders.splice(indexOfExisting, 1, folderObj)
} else {
this.localFolders.push(folderObj)
}
var permissionsGood = await StorageManager.checkFolderPermissions({ folderUrl: folderObj.contentUrl })
if (!permissionsGood) {
this.$toast.error('Folder permissions failed')
return
} else {
this.$toast.success('Folder permission success')
}
await this.searchFolder(folderObj.id)
if (this.isSocketConnected) {
this.$store.dispatch('downloads/linkOrphanDownloads')
}
}
},
async searchFolder(folderId) {
this.isScanning = true
var response = await StorageManager.searchFolder({ folderId })
if (response && response.localMediaItems) {
this.localMediaItems = response.localMediaItems.map((mi) => {
if (mi.coverPath) {
mi.coverPathSrc = Capacitor.convertFileSrc(mi.coverPath)
}
return mi
})
console.log('Set Local Media Items', this.localMediaItems.length)
} else {
console.log('No Local media items found')
}
this.isScanning = false
},
async resetFolder() {
await this.$localStore.setDownloadFolder(null)
this.$store.commit('downloads/setMediaScanResults', {})
this.$toast.info('Unlinked Folder')
},
jumpToAudiobook(download) {
this.show = false
this.$router.push(`/audiobook/${download.id}`)
},
async clickDeleteDownload(download) {
const { value } = await Dialog.confirm({
title: 'Confirm',
message: 'Delete this download?'
})
if (value) {
this.deleteDownload(download)
}
},
playDownload(download) {
this.$store.commit('setPlayOnLoad', true)
this.$store.commit('setPlayingDownload', download)
this.show = false
},
async deleteDownload(download) {
console.log('Delete download', download.filename)
if (this.$store.state.playingDownload && this.$store.state.playingDownload.id === download.id) {
console.warn('Deleting download when currently playing download - terminate play')
if (this.$refs.streamContainer) {
this.$refs.streamContainer.cancelStream()
}
}
if (download.contentUrl) {
await StorageManager.delete(download)
}
this.$store.commit('downloads/removeDownload', download)
},
onDownloadProgress(data) {
var progress = data.progress
var audiobookId = data.audiobookId
var downloadObj = this.$store.getters['downloads/getDownload'](audiobookId)
if (downloadObj) {
this.$set(this.downloadingProgress, audiobookId, progress)
}
},
async init() { async init() {
this.localFolders = (await this.$db.loadFolders()) || [] this.localFolders = (await this.$db.loadFolders()) || []
AudioDownloader.addListener('onDownloadProgress', this.onDownloadProgress)
} }
}, },
mounted() { mounted() {
this.init() this.init()
},
beforeDestroy() {
AudioDownloader.removeListener('onDownloadProgress', this.onDownloadProgress)
} }
} }
</script> </script>

View file

@ -24,7 +24,7 @@ class DbService {
} }
loadFolders() { loadFolders() {
return DbManager.localFoldersFromWebView().then((data) => { return DbManager.getLocalFolders_WV().then((data) => {
console.log('Loaded local folders', JSON.stringify(data)) console.log('Loaded local folders', JSON.stringify(data))
if (data.folders && typeof data.folders == 'string') { if (data.folders && typeof data.folders == 'string') {
return JSON.parse(data.folders) return JSON.parse(data.folders)
@ -36,8 +36,15 @@ class DbService {
}) })
} }
loadLocalMediaItemsInFolder(folderId) { getLocalFolder(folderId) {
return DbManager.loadMediaItemsInFolder({ folderId }).then((data) => { return DbManager.getLocalFolder_WV({ folderId }).then((data) => {
console.log('Got local folder', JSON.stringify(data))
return data
})
}
getLocalMediaItemsInFolder(folderId) {
return DbManager.getLocalMediaItemsInFolder_WV({ folderId }).then((data) => {
console.log('Loaded local media items in folder', JSON.stringify(data)) console.log('Loaded local media items in folder', JSON.stringify(data))
if (data.localMediaItems && typeof data.localMediaItems == 'string') { if (data.localMediaItems && typeof data.localMediaItems == 'string') {
return JSON.parse(data.localMediaItems) return JSON.parse(data.localMediaItems)