mirror of
https://github.com/sudoxnym/audiobookshelf-atv.git
synced 2026-04-14 19:46:30 +00:00
Update android scanner and add local media file page, scan library items
This commit is contained in:
parent
12de187b7a
commit
ee942c6704
9 changed files with 540 additions and 45 deletions
|
|
@ -34,7 +34,11 @@ open class MediaType(var metadata:MediaTypeMetadata, var coverPath:String?) {
|
|||
@JsonIgnore
|
||||
open fun getAudioTracks():List<AudioTrack> { return mutableListOf() }
|
||||
@JsonIgnore
|
||||
open fun setAudioTracks(audioTracks:List<AudioTrack>) { }
|
||||
open fun setAudioTracks(audioTracks:MutableList<AudioTrack>) { }
|
||||
@JsonIgnore
|
||||
open fun addAudioTrack(audioTrack:AudioTrack) { }
|
||||
@JsonIgnore
|
||||
open fun removeAudioTrack(localFileId:String) { }
|
||||
}
|
||||
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
|
|
@ -51,7 +55,7 @@ class Podcast(
|
|||
return tracks.filterNotNull()
|
||||
}
|
||||
@JsonIgnore
|
||||
override fun setAudioTracks(audioTracks:List<AudioTrack>) {
|
||||
override fun setAudioTracks(audioTracks:MutableList<AudioTrack>) {
|
||||
// Remove episodes no longer there in tracks
|
||||
episodes = episodes.filter { ep ->
|
||||
audioTracks.find { it.localFileId == ep.audioTrack?.localFileId } != null
|
||||
|
|
@ -64,6 +68,15 @@ class Podcast(
|
|||
}
|
||||
}
|
||||
}
|
||||
@JsonIgnore
|
||||
override fun addAudioTrack(audioTrack:AudioTrack) {
|
||||
var newEpisode = PodcastEpisode("local_" + audioTrack.localFileId,episodes.size + 1,null,null,audioTrack.title,null,null,null,audioTrack)
|
||||
episodes.add(newEpisode)
|
||||
}
|
||||
@JsonIgnore
|
||||
override fun removeAudioTrack(localFileId:String) {
|
||||
episodes.removeIf { it.audioTrack?.localFileId == localFileId }
|
||||
}
|
||||
}
|
||||
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
|
|
@ -73,7 +86,7 @@ class Book(
|
|||
var tags:List<String>,
|
||||
var audioFiles:List<AudioFile>,
|
||||
var chapters:List<BookChapter>,
|
||||
var tracks:List<AudioTrack>?,
|
||||
var tracks:MutableList<AudioTrack>?,
|
||||
var size:Long?,
|
||||
var duration:Double?
|
||||
) : MediaType(metadata, coverPath) {
|
||||
|
|
@ -82,9 +95,30 @@ class Book(
|
|||
return tracks ?: mutableListOf()
|
||||
}
|
||||
@JsonIgnore
|
||||
override fun setAudioTracks(audioTracks:List<AudioTrack>) {
|
||||
override fun setAudioTracks(audioTracks:MutableList<AudioTrack>) {
|
||||
tracks = audioTracks
|
||||
|
||||
// TODO: Is it necessary to calculate this each time? check if can remove safely
|
||||
var totalDuration = 0.0
|
||||
tracks?.forEach {
|
||||
totalDuration += it.duration
|
||||
}
|
||||
duration = totalDuration
|
||||
}
|
||||
@JsonIgnore
|
||||
override fun addAudioTrack(audioTrack:AudioTrack) {
|
||||
tracks?.add(audioTrack)
|
||||
|
||||
var totalDuration = 0.0
|
||||
tracks?.forEach {
|
||||
totalDuration += it.duration
|
||||
}
|
||||
duration = totalDuration
|
||||
}
|
||||
@JsonIgnore
|
||||
override fun removeAudioTrack(localFileId:String) {
|
||||
tracks?.removeIf { it.localFileId == localFileId }
|
||||
|
||||
var totalDuration = 0.0
|
||||
tracks?.forEach {
|
||||
totalDuration += it.duration
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
package com.audiobookshelf.app.data
|
||||
|
||||
import android.util.Log
|
||||
import com.audiobookshelf.app.plugins.AbsDownloader
|
||||
import io.paperdb.Paper
|
||||
import org.json.JSONObject
|
||||
|
||||
|
|
@ -57,6 +58,10 @@ class DbManager {
|
|||
}
|
||||
}
|
||||
|
||||
fun saveLocalLibraryItem(localLibraryItem:LocalLibraryItem) {
|
||||
Paper.book("localLibraryItems").write(localLibraryItem.id, localLibraryItem)
|
||||
}
|
||||
|
||||
fun saveLocalFolder(localFolder:LocalFolder) {
|
||||
Paper.book("localFolders").write(localFolder.id,localFolder)
|
||||
}
|
||||
|
|
@ -84,6 +89,25 @@ class DbManager {
|
|||
Paper.book("localFolders").delete(folderId)
|
||||
}
|
||||
|
||||
fun saveDownloadItem(downloadItem: AbsDownloader.DownloadItem) {
|
||||
Paper.book("downloadItems").write(downloadItem.id, downloadItem)
|
||||
}
|
||||
|
||||
fun removeDownloadItem(downloadItemId:String) {
|
||||
Paper.book("downloadItems").delete(downloadItemId)
|
||||
}
|
||||
|
||||
fun getDownloadItems():List<AbsDownloader.DownloadItem> {
|
||||
var downloadItems:MutableList<AbsDownloader.DownloadItem> = mutableListOf()
|
||||
Paper.book("downloadItems").allKeys.forEach {
|
||||
var downloadItem:AbsDownloader.DownloadItem? = Paper.book("downloadItems").read(it)
|
||||
if (downloadItem != null) {
|
||||
downloadItems.add(downloadItem)
|
||||
}
|
||||
}
|
||||
return downloadItems
|
||||
}
|
||||
|
||||
fun saveObject(db:String, key:String, value:JSONObject) {
|
||||
Log.d(tag, "Saving Object $key ${value.toString()}")
|
||||
Paper.book(db).write(key, value)
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ data class LocalLibraryItem(
|
|||
var libraryItemId:String?,
|
||||
var folderId:String,
|
||||
var absolutePath:String,
|
||||
var contentUrl:String,
|
||||
var isInvalid:Boolean,
|
||||
var mediaType:String,
|
||||
var media:MediaType,
|
||||
|
|
@ -42,7 +43,7 @@ data class LocalLibraryItem(
|
|||
}
|
||||
|
||||
@JsonIgnore
|
||||
fun updateFromScan(audioTracks:List<AudioTrack>, _localFiles:MutableList<LocalFile>) {
|
||||
fun updateFromScan(audioTracks:MutableList<AudioTrack>, _localFiles:MutableList<LocalFile>) {
|
||||
media.setAudioTracks(audioTracks)
|
||||
localFiles = _localFiles
|
||||
|
||||
|
|
@ -125,10 +126,10 @@ data class LocalMediaItem(
|
|||
if (mediaType == "book") {
|
||||
var chapters = getAudiobookChapters()
|
||||
var book = Book(mediaMetadata as BookMetadata, coverAbsolutePath, mutableListOf(), mutableListOf(), chapters,audioTracks,getTotalSize(),getDuration())
|
||||
return LocalLibraryItem(id, null, folderId, absolutePath, false,mediaType, book, localFiles, coverContentUrl, coverAbsolutePath,true)
|
||||
return LocalLibraryItem(id, null, folderId, absolutePath, contentUrl, false,mediaType, book, localFiles, coverContentUrl, coverAbsolutePath,true)
|
||||
} else {
|
||||
var podcast = Podcast(mediaMetadata as PodcastMetadata, coverAbsolutePath, mutableListOf(), mutableListOf(), false)
|
||||
return LocalLibraryItem(id, null, folderId, absolutePath, false, mediaType, podcast,localFiles,coverContentUrl, coverAbsolutePath, true)
|
||||
return LocalLibraryItem(id, null, folderId, absolutePath, contentUrl, false, mediaType, podcast,localFiles,coverContentUrl, coverAbsolutePath, true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -142,12 +143,17 @@ data class LocalFile(
|
|||
var simplePath:String,
|
||||
var mimeType:String?,
|
||||
var size:Long
|
||||
)
|
||||
) {
|
||||
@JsonIgnore
|
||||
fun isAudioFile():Boolean {
|
||||
return mimeType?.startsWith("audio") == true
|
||||
}
|
||||
}
|
||||
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
data class LocalFolder(
|
||||
var id:String,
|
||||
var name:String?,
|
||||
var name:String,
|
||||
var contentUrl:String,
|
||||
var absolutePath:String,
|
||||
var simplePath:String,
|
||||
|
|
|
|||
|
|
@ -8,3 +8,8 @@ data class FolderScanResult(
|
|||
val localFolder:LocalFolder,
|
||||
val localLibraryItems:List<LocalLibraryItem>,
|
||||
)
|
||||
|
||||
data class LocalLibraryItemScanResult(
|
||||
val updated:Boolean,
|
||||
val localLibraryItem:LocalLibraryItem,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import com.arthenica.ffmpegkit.FFmpegKitConfig
|
|||
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.module.kotlin.jacksonObjectMapper
|
||||
import com.fasterxml.jackson.module.kotlin.readValue
|
||||
|
||||
|
|
@ -63,6 +64,7 @@ class FolderScanner(var ctx: Context) {
|
|||
|
||||
var itemFolderName = itemFolder.name ?: ""
|
||||
var itemId = getLocalLibraryItemId(itemFolder.id)
|
||||
var itemContentUrl = itemFolder.uri.toString()
|
||||
|
||||
var existingItem = existingLocalLibraryItems.find { emi -> emi.id == itemId }
|
||||
var existingLocalFiles = existingItem?.localFiles ?: mutableListOf()
|
||||
|
|
@ -137,7 +139,6 @@ class FolderScanner(var ctx: Context) {
|
|||
}
|
||||
|
||||
startOffset += audioProbeResult.duration
|
||||
index++
|
||||
isNewOrUpdated = true
|
||||
} else {
|
||||
audioTrackToAdd = existingAudioTrack
|
||||
|
|
@ -203,4 +204,177 @@ class FolderScanner(var ctx: Context) {
|
|||
FolderScanResult(mediaItemsAdded, mediaItemsUpdated, mediaItemsRemoved, mediaItemsUpToDate, localFolder, mutableListOf())
|
||||
}
|
||||
}
|
||||
|
||||
// Scan item after download and create local library item
|
||||
fun scanDownloadItem(downloadItem: AbsDownloader.DownloadItem):LocalLibraryItem? {
|
||||
var folderDf = DocumentFileCompat.fromUri(ctx, Uri.parse(downloadItem.localFolder.contentUrl))
|
||||
var foldersFound = folderDf?.search(false, DocumentFileType.FOLDER) ?: mutableListOf()
|
||||
var itemFolderUrl:String = ""
|
||||
foldersFound.forEach {
|
||||
if (it.name == downloadItem.itemTitle) {
|
||||
itemFolderUrl = it.uri.toString()
|
||||
}
|
||||
}
|
||||
|
||||
if (itemFolderUrl == "") {
|
||||
Log.d(tag, "scanDownloadItem failed to find media folder")
|
||||
return null
|
||||
}
|
||||
var df: DocumentFile? = DocumentFileCompat.fromUri(ctx, Uri.parse(itemFolderUrl))
|
||||
|
||||
if (df == null) {
|
||||
Log.e(tag, "Folder Doc File Invalid ${downloadItem.itemFolderPath}")
|
||||
return null
|
||||
}
|
||||
Log.d(tag, "scanDownloadItem starting for ${downloadItem.itemFolderPath} | ${df.uri}")
|
||||
|
||||
// Search for files in media item folder
|
||||
var filesFound = df.search(false, DocumentFileType.FILE, arrayOf("audio/*", "image/*"))
|
||||
Log.d(tag, "scanDownloadItem ${filesFound.size} files found in ${downloadItem.itemFolderPath}")
|
||||
|
||||
var localLibraryItem = LocalLibraryItem("local_${downloadItem.id}", downloadItem.id, downloadItem.localFolder.id, downloadItem.itemFolderPath,itemFolderUrl, false, downloadItem.mediaType, downloadItem.media, mutableListOf(), null, null, true)
|
||||
|
||||
var localFiles:MutableList<LocalFile> = mutableListOf()
|
||||
var audioTracks:MutableList<AudioTrack> = mutableListOf()
|
||||
|
||||
filesFound.forEach { docFile ->
|
||||
var itemPart = downloadItem.downloadItemParts.find { itemPart ->
|
||||
itemPart.filename == docFile.name
|
||||
}
|
||||
if (itemPart == null) {
|
||||
Log.e(tag, "scanDownloadItem: Item part not found for doc file ${docFile.name} | ${docFile.getAbsolutePath(ctx)} | ${docFile.uri}")
|
||||
} else if (itemPart.audioTrack != null) { // Is audio track
|
||||
var audioTrackFromServer = itemPart.audioTrack
|
||||
|
||||
var localFileId = DeviceManager.getBase64Id(docFile.id)
|
||||
var localFile = LocalFile(localFileId,docFile.name,docFile.uri.toString(),docFile.getAbsolutePath(ctx),docFile.getSimplePath(ctx),docFile.mimeType,docFile.length())
|
||||
localFiles.add(localFile)
|
||||
|
||||
// TODO: Make asynchronous
|
||||
var session = FFprobeKit.execute("-i \"${localFile.absolutePath}\" -print_format json -show_format -show_streams -select_streams a -show_chapters -loglevel quiet")
|
||||
|
||||
val audioProbeResult = jacksonObjectMapper().readValue<AudioProbeResult>(session.output)
|
||||
Log.d(tag, "Probe Result DATA ${audioProbeResult.duration} | ${audioProbeResult.size} | ${audioProbeResult.title} | ${audioProbeResult.artist}")
|
||||
|
||||
// Create new audio track
|
||||
var track = AudioTrack(audioTrackFromServer?.index ?: 0, audioTrackFromServer?.startOffset ?: 0.0, audioProbeResult.duration, localFile.filename ?: "", localFile.contentUrl, localFile.mimeType ?: "", null, true, localFileId, audioProbeResult)
|
||||
audioTracks.add(track)
|
||||
} else { // Cover image
|
||||
var localFileId = DeviceManager.getBase64Id(docFile.id)
|
||||
var localFile = LocalFile(localFileId,docFile.name,docFile.uri.toString(),docFile.getAbsolutePath(ctx),docFile.getSimplePath(ctx),docFile.mimeType,docFile.length())
|
||||
localFiles.add(localFile)
|
||||
|
||||
localLibraryItem.coverAbsolutePath = localFile.absolutePath
|
||||
localLibraryItem.coverContentUrl = localFile.contentUrl
|
||||
}
|
||||
}
|
||||
|
||||
if (audioTracks.isEmpty()) {
|
||||
Log.d(tag, "scanDownloadItem did not find any audio tracks in folder for ${downloadItem.itemFolderPath}")
|
||||
return null
|
||||
}
|
||||
|
||||
localLibraryItem.media.setAudioTracks(audioTracks)
|
||||
localLibraryItem.localFiles = localFiles
|
||||
|
||||
DeviceManager.dbManager.saveLocalLibraryItem(localLibraryItem)
|
||||
|
||||
return localLibraryItem
|
||||
}
|
||||
|
||||
fun scanLocalLibraryItem(localLibraryItem:LocalLibraryItem, forceAudioProbe:Boolean):LocalLibraryItemScanResult? {
|
||||
var df: DocumentFile? = DocumentFileCompat.fromUri(ctx, Uri.parse(localLibraryItem.contentUrl))
|
||||
|
||||
if (df == null) {
|
||||
Log.e(tag, "Item Folder Doc File Invalid ${localLibraryItem.absolutePath}")
|
||||
return null
|
||||
}
|
||||
Log.d(tag, "scanLocalLibraryItem starting for ${localLibraryItem.absolutePath} | ${df.uri}")
|
||||
|
||||
var wasUpdated = false
|
||||
|
||||
// Search for files in media item folder
|
||||
var filesFound = df.search(false, DocumentFileType.FILE, arrayOf("audio/*", "image/*"))
|
||||
Log.d(tag, "scanLocalLibraryItem ${filesFound.size} files found in ${localLibraryItem.absolutePath}")
|
||||
|
||||
filesFound.forEach {
|
||||
try {
|
||||
Log.d(tag, "Checking file found ${it.name} | ${it.id}")
|
||||
}catch(e:Exception) {
|
||||
Log.d(tag, "Check file found exception", e)
|
||||
}
|
||||
}
|
||||
|
||||
var existingAudioTracks = localLibraryItem.media.getAudioTracks()
|
||||
|
||||
// Remove any files no longer found in library item folder
|
||||
var existingLocalFileIds = localLibraryItem.localFiles.map { it.id }
|
||||
existingLocalFileIds.forEach { localFileId ->
|
||||
Log.d(tag, "Checking local file id is there $localFileId")
|
||||
if (filesFound.find { DeviceManager.getBase64Id(it.id) == localFileId } == null) {
|
||||
Log.d(tag, "scanLocalLibraryItem file $localFileId was removed from ${localLibraryItem.absolutePath}")
|
||||
localLibraryItem.localFiles.removeIf { it.id == localFileId }
|
||||
|
||||
if (existingAudioTracks.find { it.localFileId == localFileId } != null) {
|
||||
Log.d(tag, "scanLocalLibraryItem audio track file ${localFileId} was removed from ${localLibraryItem.absolutePath}")
|
||||
localLibraryItem.media.removeAudioTrack(localFileId)
|
||||
}
|
||||
wasUpdated = true
|
||||
}
|
||||
}
|
||||
|
||||
filesFound.forEach { docFile ->
|
||||
var localFileId = DeviceManager.getBase64Id(docFile.id)
|
||||
var existingLocalFile = localLibraryItem.localFiles.find { it.id == localFileId }
|
||||
|
||||
if (existingLocalFile == null || (existingLocalFile.isAudioFile() && forceAudioProbe)) {
|
||||
|
||||
var localFile = existingLocalFile ?: LocalFile(localFileId,docFile.name,docFile.uri.toString(),docFile.getAbsolutePath(ctx),docFile.getSimplePath(ctx),docFile.mimeType,docFile.length())
|
||||
if (existingLocalFile == null) {
|
||||
localLibraryItem.localFiles.add(localFile)
|
||||
Log.d(tag, "scanLocalLibraryItem new file found ${localFile.filename}")
|
||||
}
|
||||
|
||||
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<AudioProbeResult>(session.output)
|
||||
Log.d(tag, "Probe Result DATA ${audioProbeResult.duration} | ${audioProbeResult.size} | ${audioProbeResult.title} | ${audioProbeResult.artist}")
|
||||
|
||||
var existingTrack = existingAudioTracks.find { audioTrack ->
|
||||
audioTrack.localFileId == localFile.id
|
||||
}
|
||||
|
||||
if (existingTrack == null) {
|
||||
// Create new audio track
|
||||
var lastTrack = existingAudioTracks.lastOrNull()
|
||||
var startOffset = (lastTrack?.startOffset ?: 0.0) + (lastTrack?.duration ?: 0.0)
|
||||
var track = AudioTrack(existingAudioTracks.size, startOffset, audioProbeResult.duration, localFile.filename ?: "", localFile.contentUrl, localFile.mimeType ?: "", null, true, localFileId, audioProbeResult)
|
||||
localLibraryItem.media.addAudioTrack(track)
|
||||
wasUpdated = true
|
||||
} else {
|
||||
existingTrack.audioProbeResult = audioProbeResult
|
||||
// TODO: Update data found from probe
|
||||
wasUpdated = true
|
||||
}
|
||||
} else { // Check if cover is empty
|
||||
if (localLibraryItem.coverContentUrl == null) {
|
||||
Log.d(tag, "scanLocalLibraryItem setting cover for ${localLibraryItem.media.metadata.title}")
|
||||
localLibraryItem.coverContentUrl = localFile.contentUrl
|
||||
localLibraryItem.coverAbsolutePath = localFile.absolutePath
|
||||
wasUpdated = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (wasUpdated) {
|
||||
Log.d(tag, "Local library item was updated - saving it")
|
||||
DeviceManager.dbManager.saveLocalLibraryItem(localLibraryItem)
|
||||
} else {
|
||||
Log.d(tag, "Local library item was up-to-date")
|
||||
}
|
||||
return LocalLibraryItemScanResult(wasUpdated, localLibraryItem)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,8 +6,7 @@ import android.net.Uri
|
|||
import android.os.Build
|
||||
import android.util.Log
|
||||
import com.audiobookshelf.app.MainActivity
|
||||
import com.audiobookshelf.app.data.LibraryItem
|
||||
import com.audiobookshelf.app.data.LocalFolder
|
||||
import com.audiobookshelf.app.data.*
|
||||
import com.audiobookshelf.app.device.DeviceManager
|
||||
import com.audiobookshelf.app.device.FolderScanner
|
||||
import com.audiobookshelf.app.server.ApiHandler
|
||||
|
|
@ -37,11 +36,14 @@ class AbsDownloader : Plugin() {
|
|||
|
||||
data class DownloadItemPart(
|
||||
val id: String,
|
||||
val name: String,
|
||||
val filename: String,
|
||||
val destinationPath:String,
|
||||
val itemTitle: String,
|
||||
val serverPath: String,
|
||||
val folderName: String,
|
||||
val localFolderName: String,
|
||||
val localFolderId: String,
|
||||
val audioTrack: AudioTrack?,
|
||||
var completed:Boolean,
|
||||
@JsonIgnore val uri: Uri,
|
||||
@JsonIgnore val destinationUri: Uri,
|
||||
var downloadId: Long?,
|
||||
|
|
@ -50,8 +52,8 @@ class AbsDownloader : Plugin() {
|
|||
@JsonIgnore
|
||||
fun getDownloadRequest(): DownloadManager.Request {
|
||||
var dlRequest = DownloadManager.Request(uri)
|
||||
dlRequest.setTitle(name)
|
||||
dlRequest.setDescription("Downloading to $folderName for book $itemTitle")
|
||||
dlRequest.setTitle(filename)
|
||||
dlRequest.setDescription("Downloading to $localFolderName for book $itemTitle")
|
||||
dlRequest.setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED)
|
||||
dlRequest.setDestinationUri(destinationUri)
|
||||
return dlRequest
|
||||
|
|
@ -60,8 +62,11 @@ class AbsDownloader : Plugin() {
|
|||
|
||||
data class DownloadItem(
|
||||
val id: String,
|
||||
val mediaType: String,
|
||||
val itemFolderPath:String,
|
||||
val localFolder: LocalFolder,
|
||||
val itemTitle: String,
|
||||
val media:MediaType,
|
||||
val downloadItemParts: MutableList<DownloadItemPart>
|
||||
)
|
||||
|
||||
|
|
@ -91,16 +96,21 @@ class AbsDownloader : Plugin() {
|
|||
var localFolderId = call.data.getString("localFolderId").toString()
|
||||
Log.d(tag, "Download library item $libraryItemId to folder $localFolderId")
|
||||
|
||||
if (downloadQueue.find { it.id == libraryItemId } != null) {
|
||||
Log.d(tag, "Download already started for this library item $libraryItemId")
|
||||
return call.resolve(JSObject("{\"error\":\"Download already started for this library item\"}"))
|
||||
}
|
||||
|
||||
apiHandler.getLibraryItem(libraryItemId) { libraryItem ->
|
||||
Log.d(tag, "Got library item from server ${libraryItem.id}")
|
||||
var localFolder = DeviceManager.dbManager.getLocalFolder(localFolderId)
|
||||
if (localFolder != null) {
|
||||
startLibraryItemDownload(libraryItem, localFolder)
|
||||
call.resolve()
|
||||
} else {
|
||||
call.resolve(JSObject("{\"error\":\"Local Folder Not Found\"}"))
|
||||
}
|
||||
}
|
||||
|
||||
call.resolve(JSObject("{\"error\":\"Library Item not found\"}"))
|
||||
}
|
||||
|
||||
// Clean folder path so it can be used in URL
|
||||
|
|
@ -128,22 +138,23 @@ class AbsDownloader : Plugin() {
|
|||
|
||||
fun startLibraryItemDownload(libraryItem: LibraryItem, localFolder: LocalFolder) {
|
||||
if (libraryItem.mediaType == "book") {
|
||||
var bookMedia = libraryItem.media as com.audiobookshelf.app.data.Book
|
||||
var bookTitle = bookMedia.metadata.title
|
||||
var tracks = bookMedia.tracks ?: mutableListOf()
|
||||
var bookTitle = libraryItem.media.metadata.title
|
||||
var tracks = libraryItem.media.getAudioTracks()
|
||||
Log.d(tag, "Starting library item download with ${tracks.size} tracks")
|
||||
var downloadItem = DownloadItem(libraryItem.id, localFolder, bookTitle, mutableListOf())
|
||||
var itemFolderPath = localFolder.absolutePath + "/" + bookTitle
|
||||
tracks.forEach { audioFile ->
|
||||
var serverPath = "/s/item/${libraryItem.id}/${cleanRelPath(audioFile.relPath)}"
|
||||
var destinationFilename = getFilenameFromRelPath(audioFile.relPath)
|
||||
Log.d(tag, "Audio File Server Path $serverPath | AF RelPath ${audioFile.relPath} | LocalFolder Path ${localFolder.absolutePath} | DestName ${destinationFilename}")
|
||||
var downloadItem = DownloadItem(libraryItem.id, libraryItem.mediaType, itemFolderPath, localFolder, bookTitle, libraryItem.media, mutableListOf())
|
||||
|
||||
|
||||
// Create download item part for each audio track
|
||||
tracks.forEach { audioTrack ->
|
||||
var serverPath = "/s/item/${libraryItem.id}/${cleanRelPath(audioTrack.relPath)}"
|
||||
var destinationFilename = getFilenameFromRelPath(audioTrack.relPath)
|
||||
Log.d(tag, "Audio File Server Path $serverPath | AF RelPath ${audioTrack.relPath} | LocalFolder Path ${localFolder.absolutePath} | DestName ${destinationFilename}")
|
||||
var destinationFile = File("$itemFolderPath/$destinationFilename")
|
||||
var destinationUri = Uri.fromFile(destinationFile)
|
||||
var downloadUri = Uri.parse("${DeviceManager.serverAddress}${serverPath}?token=${DeviceManager.token}")
|
||||
Log.d(tag, "Audio File Destination Uri $destinationUri | Download URI $downloadUri")
|
||||
var downloadItemPart = DownloadItemPart(UUID.randomUUID().toString(), destinationFilename, bookTitle, serverPath, localFolder.name
|
||||
?: "", localFolder.id, downloadUri, destinationUri, null, 0)
|
||||
var downloadItemPart = DownloadItemPart(DeviceManager.getBase64Id(destinationFile.absolutePath), destinationFilename, destinationFile.absolutePath, bookTitle, serverPath, localFolder.name, localFolder.id, audioTrack, false, downloadUri, destinationUri, null, 0)
|
||||
|
||||
downloadItem.downloadItemParts.add(downloadItemPart)
|
||||
|
||||
|
|
@ -151,8 +162,24 @@ class AbsDownloader : Plugin() {
|
|||
var downloadId = downloadManager.enqueue(dlRequest)
|
||||
downloadItemPart.downloadId = downloadId
|
||||
}
|
||||
Log.d(tag, "Done queueing downloads ${downloadQueue.size}")
|
||||
|
||||
if (downloadItem.downloadItemParts.isNotEmpty()) {
|
||||
// Add cover download item
|
||||
if (libraryItem.media.coverPath != null && libraryItem.media.coverPath?.isNotEmpty() == true) {
|
||||
var serverPath = "/api/items/${libraryItem.id}/cover?format=jpeg"
|
||||
var destinationFilename = "cover.jpg"
|
||||
var destinationFile = File("$itemFolderPath/$destinationFilename")
|
||||
var destinationUri = Uri.fromFile(destinationFile)
|
||||
var downloadUri = Uri.parse("${DeviceManager.serverAddress}${serverPath}&token=${DeviceManager.token}")
|
||||
var downloadItemPart = DownloadItemPart(DeviceManager.getBase64Id(destinationFile.absolutePath), destinationFilename, destinationFile.absolutePath, bookTitle, serverPath, localFolder.name, localFolder.id, null, false, downloadUri, destinationUri, null, 0)
|
||||
|
||||
downloadItem.downloadItemParts.add(downloadItemPart)
|
||||
|
||||
var dlRequest = downloadItemPart.getDownloadRequest()
|
||||
var downloadId = downloadManager.enqueue(dlRequest)
|
||||
downloadItemPart.downloadId = downloadId
|
||||
}
|
||||
|
||||
// TODO: Cannot create new text file here but can download here... ??
|
||||
// var abmetadataFile = File(itemFolderPath, "abmetadata.abs")
|
||||
// abmetadataFile.createNewFileIfPossible()
|
||||
|
|
@ -160,6 +187,7 @@ class AbsDownloader : Plugin() {
|
|||
|
||||
downloadQueue.add(downloadItem)
|
||||
startWatchingDownloads(downloadItem)
|
||||
DeviceManager.dbManager.saveDownloadItem(downloadItem)
|
||||
}
|
||||
} else {
|
||||
// TODO: Download podcast episode(s)
|
||||
|
|
@ -168,26 +196,38 @@ class AbsDownloader : Plugin() {
|
|||
|
||||
fun startWatchingDownloads(downloadItem: DownloadItem) {
|
||||
GlobalScope.launch(Dispatchers.IO) {
|
||||
while (downloadItem.downloadItemParts.isNotEmpty()) {
|
||||
while (downloadItem.downloadItemParts.find { !it.completed } != null) { // While some item is not completed
|
||||
var numPartsBefore = downloadItem.downloadItemParts.size
|
||||
checkDownloads(downloadItem)
|
||||
|
||||
// Keep database updated as item parts finish downloading
|
||||
if (downloadItem.downloadItemParts.size > 0 && downloadItem.downloadItemParts.size != numPartsBefore) {
|
||||
Log.d(tag, "Save download item on num parts changed from $numPartsBefore to ${downloadItem.downloadItemParts.size}")
|
||||
DeviceManager.dbManager.saveDownloadItem(downloadItem)
|
||||
}
|
||||
|
||||
notifyListeners("onItemDownloadUpdate", JSObject(jacksonObjectMapper().writeValueAsString(downloadItem)))
|
||||
delay(500)
|
||||
}
|
||||
|
||||
var folderScanResult = folderScanner.scanForMediaItems(downloadItem.localFolder, false)
|
||||
var localLibraryItem = folderScanner.scanDownloadItem(downloadItem)
|
||||
DeviceManager.dbManager.removeDownloadItem(downloadItem.id)
|
||||
downloadQueue.remove(downloadItem)
|
||||
|
||||
Log.d(tag, "Item download complete ${downloadItem.itemTitle} | local library item id: ${localLibraryItem?.id} | Items remaining in Queue ${downloadQueue.size}")
|
||||
|
||||
Log.d(tag, "Item download complete ${downloadItem.itemTitle}")
|
||||
var jsobj = JSObject()
|
||||
jsobj.put("libraryItemId", downloadItem.id)
|
||||
jsobj.put("localFolderId", downloadItem.localFolder.id)
|
||||
jsobj.put("folderScanResult", JSObject(jacksonObjectMapper().writeValueAsString(folderScanResult)))
|
||||
if (localLibraryItem != null) {
|
||||
jsobj.put("localLibraryItem", JSObject(jacksonObjectMapper().writeValueAsString(localLibraryItem)))
|
||||
}
|
||||
notifyListeners("onItemDownloadComplete", jsobj)
|
||||
}
|
||||
}
|
||||
|
||||
fun checkDownloads(downloadItem: DownloadItem) {
|
||||
var itemParts = downloadItem.downloadItemParts.map { it }
|
||||
Log.d(tag, "Check Downloads ${itemParts.size}")
|
||||
for (downloadItemPart in itemParts) {
|
||||
if (downloadItemPart.downloadId != null) {
|
||||
var dlid = downloadItemPart.downloadId!!
|
||||
|
|
@ -195,24 +235,26 @@ class AbsDownloader : Plugin() {
|
|||
downloadManager.query(query).use {
|
||||
if (it.moveToFirst()) {
|
||||
val totalBytes = it.getInt(it.getColumnIndex(DownloadManager.COLUMN_TOTAL_SIZE_BYTES))
|
||||
Log.d(tag, "Download ${downloadItemPart.name} bytes $totalBytes")
|
||||
val downloadStatus = it.getInt(it.getColumnIndex(DownloadManager.COLUMN_STATUS))
|
||||
val bytesDownloadedSoFar = it.getInt(it.getColumnIndex(DownloadManager.COLUMN_BYTES_DOWNLOADED_SO_FAR))
|
||||
Log.d(tag, "Download ${downloadItemPart.filename} bytes $totalBytes | bytes dled $bytesDownloadedSoFar | downloadStatus $downloadStatus")
|
||||
|
||||
if (downloadStatus == DownloadManager.STATUS_SUCCESSFUL) {
|
||||
Log.d(tag, "Download ${downloadItemPart.name} Done")
|
||||
downloadItem.downloadItemParts.remove(downloadItemPart)
|
||||
Log.d(tag, "Download ${downloadItemPart.filename} Done")
|
||||
// downloadItem.downloadItemParts.remove(downloadItemPart)
|
||||
downloadItemPart.completed = true
|
||||
} else if (downloadStatus == DownloadManager.STATUS_FAILED) {
|
||||
Log.d(tag, "Download ${downloadItemPart.name} Failed")
|
||||
Log.d(tag, "Download ${downloadItemPart.filename} Failed")
|
||||
downloadItem.downloadItemParts.remove(downloadItemPart)
|
||||
// downloadItemPart.completed = true
|
||||
} else {
|
||||
//update progress
|
||||
val percentProgress = if (totalBytes > 0) ((bytesDownloadedSoFar * 100L) / totalBytes) else 0
|
||||
Log.d(tag, "${downloadItemPart.name} Progress = $percentProgress%")
|
||||
Log.d(tag, "${downloadItemPart.filename} Progress = $percentProgress%")
|
||||
downloadItemPart.progress = percentProgress
|
||||
}
|
||||
} else {
|
||||
Log.d(tag, "Download ${downloadItemPart.name} not found in dlmanager")
|
||||
Log.d(tag, "Download ${downloadItemPart.filename} not found in dlmanager")
|
||||
downloadItem.downloadItemParts.remove(downloadItemPart)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,11 +12,15 @@ import com.anggrayudi.storage.callback.StorageAccessCallback
|
|||
import com.anggrayudi.storage.file.*
|
||||
import com.audiobookshelf.app.MainActivity
|
||||
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.module.kotlin.jacksonObjectMapper
|
||||
import com.getcapacitor.*
|
||||
import com.getcapacitor.annotation.CapacitorPlugin
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@CapacitorPlugin(name = "AbsFileSystem")
|
||||
class AbsFileSystem : Plugin() {
|
||||
|
|
@ -68,7 +72,7 @@ class AbsFileSystem : Plugin() {
|
|||
var simplePath = folder.getSimplePath(activity)
|
||||
var folderId = android.util.Base64.encodeToString(folder.id.toByteArray(), android.util.Base64.DEFAULT)
|
||||
|
||||
var localFolder = LocalFolder(folderId, 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)))
|
||||
|
|
@ -155,6 +159,34 @@ class AbsFileSystem : Plugin() {
|
|||
call.resolve()
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
fun removeLocalLibraryItem(call: PluginCall) {
|
||||
var localLibraryItemId = call.data.getString("localLibraryItemId", "").toString()
|
||||
DeviceManager.dbManager.removeLocalLibraryItem(localLibraryItemId)
|
||||
call.resolve()
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
fun scanLocalLibraryItem(call: PluginCall) {
|
||||
var localLibraryItemId = call.data.getString("localLibraryItemId", "").toString()
|
||||
var forceAudioProbe = call.data.getBoolean("forceAudioProbe")
|
||||
Log.d(TAG, "Scan Local library item $localLibraryItemId | Force Audio Probe $forceAudioProbe")
|
||||
GlobalScope.launch(Dispatchers.IO) {
|
||||
var localLibraryItem: LocalLibraryItem? = DeviceManager.dbManager.getLocalLibraryItem(localLibraryItemId)
|
||||
localLibraryItem?.let {
|
||||
var folderScanner = FolderScanner(context)
|
||||
var scanResult = folderScanner.scanLocalLibraryItem(it, forceAudioProbe)
|
||||
if (scanResult == null) {
|
||||
Log.d(TAG, "NO Scan DATA")
|
||||
call.resolve(JSObject())
|
||||
} else {
|
||||
Log.d(TAG, "Scan DATA ${jacksonObjectMapper().writeValueAsString(scanResult)}")
|
||||
call.resolve(JSObject(jacksonObjectMapper().writeValueAsString(scanResult)))
|
||||
}
|
||||
} ?: call.resolve(JSObject())
|
||||
}
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
fun delete(call: PluginCall) {
|
||||
var url = call.data.getString("url", "").toString()
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@
|
|||
</div>
|
||||
<div v-else class="w-full media-item-container overflow-y-auto">
|
||||
<template v-for="mediaItem in localLibraryItems">
|
||||
<div :key="mediaItem.id" class="flex my-1">
|
||||
<nuxt-link :to="`/localMedia/item/${mediaItem.id}`" :key="mediaItem.id" class="flex my-1">
|
||||
<div class="w-12 h-12 bg-primary">
|
||||
<img v-if="mediaItem.coverPathSrc" :src="mediaItem.coverPathSrc" class="w-full h-full object-contain" />
|
||||
</div>
|
||||
|
|
@ -23,11 +23,12 @@
|
|||
<p v-else-if="mediaItem.type == 'podcast'">{{ mediaItem.media.episodes.length }} Tracks</p>
|
||||
</div>
|
||||
<div class="w-12 h-12 flex items-center justify-center">
|
||||
<button v-if="!isMissing" class="shadow-sm text-accent flex items-center justify-center rounded-full" @click.stop="play(mediaItem)">
|
||||
<span class="material-icons text-xl text-gray-300">arrow_right</span>
|
||||
<!-- <button class="shadow-sm text-accent flex items-center justify-center rounded-full" @click.stop="play(mediaItem)">
|
||||
<span class="material-icons" style="font-size: 2rem">play_arrow</span>
|
||||
</button>
|
||||
</button> -->
|
||||
</div>
|
||||
</div>
|
||||
</nuxt-link>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
177
pages/localMedia/item/_id.vue
Normal file
177
pages/localMedia/item/_id.vue
Normal file
|
|
@ -0,0 +1,177 @@
|
|||
<template>
|
||||
<div class="w-full h-full py-6 px-2">
|
||||
<div v-if="localLibraryItem" class="w-full h-full">
|
||||
<div class="flex items-center mb-4">
|
||||
<button v-if="audioTracks.length" class="shadow-sm text-accent flex items-center justify-center rounded-full" @click.stop="play">
|
||||
<span class="material-icons" style="font-size: 2rem">play_arrow</span>
|
||||
</button>
|
||||
<div class="flex-grow" />
|
||||
<ui-btn v-if="!removingItem" :loading="isScanning" small @click="clickScan">Scan</ui-btn>
|
||||
<ui-btn v-if="!removingItem" :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="removingItem" icon="delete" @click="clickDeleteItem" />
|
||||
</div>
|
||||
<p class="text-lg mb-0.5 text-white text-opacity-75">Folder: {{ folderName }}</p>
|
||||
<p class="mb-4 text-xl">{{ mediaMetadata.title }}</p>
|
||||
<div v-if="isScanning" class="w-full text-center p-4">
|
||||
<p>Scanning...</p>
|
||||
</div>
|
||||
<div v-else class="w-full media-item-container overflow-y-auto">
|
||||
<p class="text-lg mb-2">Audio Tracks</p>
|
||||
<template v-for="track in audioTracks">
|
||||
<div :key="track.localFileId" class="flex items-center my-1">
|
||||
<div class="w-12 h-12 flex items-center justify-center">
|
||||
<p class="font-mono font-bold text-xl">{{ track.index }}</p>
|
||||
</div>
|
||||
<div class="flex-grow px-2">
|
||||
<p class="text-sm">{{ track.title }}</p>
|
||||
</div>
|
||||
<div class="w-20 text-center text-gray-300">
|
||||
<p class="text-xs">{{ track.mimeType }}</p>
|
||||
<p class="text-sm">{{ $elapsedPretty(track.duration) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<p class="text-lg mb-2 pt-8">Local Files</p>
|
||||
<template v-for="file in localFiles">
|
||||
<div :key="file.id" class="flex items-center my-1">
|
||||
<div class="w-12 h-12 flex items-center justify-center">
|
||||
<img v-if="(file.mimeType || '').startsWith('image')" :src="getCapImageSrc(file.contentUrl)" class="w-full h-full object-contain" />
|
||||
<span v-else class="material-icons">music_note</span>
|
||||
</div>
|
||||
<div class="flex-grow px-2">
|
||||
<p class="text-sm">{{ file.filename }}</p>
|
||||
</div>
|
||||
<div class="w-20 text-center text-gray-300">
|
||||
<p class="text-xs">{{ file.mimeType }}</p>
|
||||
<p class="text-sm">{{ $bytesPretty(file.size) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<p class="py-4">{{ audioTracks.length }} Audio Tracks</p>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="w-full h-full">
|
||||
<p class="text-lg text-center px-8">{{ failed ? 'Failed to get local library item ' + localLibraryItemId : 'Loading..' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { Capacitor } from '@capacitor/core'
|
||||
import { Dialog } from '@capacitor/dialog'
|
||||
import { AbsFileSystem } from '@/plugins/capacitor'
|
||||
|
||||
export default {
|
||||
asyncData({ params }) {
|
||||
return {
|
||||
localLibraryItemId: params.id
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
failed: false,
|
||||
localLibraryItem: null,
|
||||
removingItem: false,
|
||||
folderId: null,
|
||||
folder: null,
|
||||
isScanning: false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
localFiles() {
|
||||
return this.localLibraryItem ? this.localLibraryItem.localFiles : []
|
||||
},
|
||||
folderName() {
|
||||
return this.folder ? this.folder.name : null
|
||||
},
|
||||
mediaType() {
|
||||
return this.localLibraryItem ? this.localLibraryItem.mediaType : null
|
||||
},
|
||||
media() {
|
||||
return this.localLibraryItem ? this.localLibraryItem.media : null
|
||||
},
|
||||
mediaMetadata() {
|
||||
return this.media ? this.media.metadata || {} : {}
|
||||
},
|
||||
audioTracks() {
|
||||
if (!this.media) return []
|
||||
if (this.mediaType == 'book') {
|
||||
return this.media.tracks || []
|
||||
} else {
|
||||
return this.media.episodes || []
|
||||
}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
play() {
|
||||
this.$eventBus.$emit('play-item', this.localLibraryItemId)
|
||||
},
|
||||
getCapImageSrc(contentUrl) {
|
||||
return Capacitor.convertFileSrc(contentUrl)
|
||||
},
|
||||
clickScan() {
|
||||
this.scanItem()
|
||||
},
|
||||
clickForceRescan() {
|
||||
this.scanItem(true)
|
||||
},
|
||||
async clickDeleteItem() {
|
||||
var deleteMessage = 'Are you sure you want to remove this local library item? (does not delete anything in your file system)'
|
||||
const { value } = await Dialog.confirm({
|
||||
title: 'Confirm',
|
||||
message: deleteMessage
|
||||
})
|
||||
if (value) {
|
||||
this.removingItem = true
|
||||
await AbsFileSystem.removeLocalLibraryItem({ localLibraryItemId: this.localLibraryItemId })
|
||||
this.removingItem = false
|
||||
this.$router.replace(`/localMedia/folders/${this.folderId}`)
|
||||
}
|
||||
},
|
||||
play(mediaItem) {
|
||||
this.$eventBus.$emit('play-item', mediaItem.id)
|
||||
},
|
||||
async scanItem(forceAudioProbe = false) {
|
||||
this.isScanning = true
|
||||
var response = await AbsFileSystem.scanLocalLibraryItem({ localLibraryItemId: this.localLibraryItemId, forceAudioProbe })
|
||||
|
||||
if (response && response.localLibraryItem) {
|
||||
if (response.updated) {
|
||||
this.$toast.success('Local item was updated')
|
||||
this.localLibraryItem = response.localLibraryItem
|
||||
} else {
|
||||
this.$toast.info('Local item was up to date')
|
||||
}
|
||||
} else {
|
||||
console.log('Failed')
|
||||
this.$toast.error('Something went wrong..')
|
||||
}
|
||||
this.isScanning = false
|
||||
},
|
||||
async init() {
|
||||
this.localLibraryItem = await this.$db.getLocalLibraryItem(this.localLibraryItemId)
|
||||
if (!this.localLibraryItem) {
|
||||
console.error('Failed to get local library item', this.localLibraryItemId)
|
||||
this.failed = true
|
||||
return
|
||||
}
|
||||
|
||||
console.log('Got local library item', JSON.stringify(this.localLibraryItem))
|
||||
this.folderId = this.localLibraryItem.folderId
|
||||
this.folder = await this.$db.getLocalFolder(this.folderId)
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.init()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.media-item-container {
|
||||
height: calc(100vh - 200px);
|
||||
max-height: calc(100vh - 200px);
|
||||
}
|
||||
</style>
|
||||
Loading…
Reference in a new issue