Add:EBook download and offline reading #187 #243

This commit is contained in:
advplyr 2023-05-21 15:02:49 -05:00
parent b1bf68b8cd
commit 2c3dff3544
18 changed files with 269 additions and 57 deletions

View file

@ -126,6 +126,7 @@ class Book(
var audioFiles:List<AudioFile>?, var audioFiles:List<AudioFile>?,
var chapters:List<BookChapter>?, var chapters:List<BookChapter>?,
var tracks:MutableList<AudioTrack>?, var tracks:MutableList<AudioTrack>?,
var ebookFile: EBookFile?,
var size:Long?, var size:Long?,
var duration:Double?, var duration:Double?,
var numTracks:Int? var numTracks:Int?
@ -179,7 +180,7 @@ class Book(
} }
@JsonIgnore @JsonIgnore
override fun getLocalCopy(): Book { override fun getLocalCopy(): Book {
return Book(metadata as BookMetadata,coverPath,tags, mutableListOf(),chapters,mutableListOf(),null,null, 0) return Book(metadata as BookMetadata,coverPath,tags, mutableListOf(),chapters,mutableListOf(), ebookFile, null,null, 0)
} }
} }

View file

@ -48,6 +48,20 @@ data class LocalFile(
if (mimeType == "video/mp4") return true if (mimeType == "video/mp4") return true
return mimeType?.startsWith("audio") == true return mimeType?.startsWith("audio") == true
} }
@JsonIgnore
fun isEBookFile():Boolean {
return getEBookFormat() != null
}
@JsonIgnore
fun getEBookFormat():String? {
if (mimeType == "application/epub+zip") return "epub"
if (mimeType == "application/pdf") return "pdf"
if (mimeType == "application/x-mobipocket-ebook") return "mobi"
if (mimeType == "application/vnd.comicbook+zip") return "cbz"
if (mimeType == "application/vnd.comicbook-rar") return "cbr"
if (mimeType == "application/vnd.amazon.mobi8-ebook") return "azw3"
return null
}
} }
@JsonIgnoreProperties(ignoreUnknown = true) @JsonIgnoreProperties(ignoreUnknown = true)

View file

@ -0,0 +1,13 @@
package com.audiobookshelf.app.data
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
@JsonIgnoreProperties(ignoreUnknown = true)
data class EBookFile(
var ino:String,
var metadata:FileMetadata?,
var ebookFormat:String,
var isLocal:Boolean,
var localFileId:String?,
var contentUrl:String?
)

View file

@ -18,6 +18,7 @@ data class LocalMediaItem(
var basePath:String, var basePath:String,
var absolutePath:String, var absolutePath:String,
var audioTracks:MutableList<AudioTrack>, var audioTracks:MutableList<AudioTrack>,
var ebookFile:EBookFile?,
var localFiles:MutableList<LocalFile>, var localFiles:MutableList<LocalFile>,
var coverContentUrl:String?, var coverContentUrl:String?,
var coverAbsolutePath:String? var coverAbsolutePath:String?
@ -61,7 +62,7 @@ data class LocalMediaItem(
val mediaMetadata = getMediaMetadata() val mediaMetadata = getMediaMetadata()
if (mediaType == "book") { if (mediaType == "book") {
val chapters = getAudiobookChapters() val chapters = getAudiobookChapters()
val book = Book(mediaMetadata as BookMetadata, coverAbsolutePath, mutableListOf(), mutableListOf(), chapters,audioTracks,getTotalSize(),getDuration(),audioTracks.size) val book = Book(mediaMetadata as BookMetadata, coverAbsolutePath, mutableListOf(), mutableListOf(), chapters,audioTracks,ebookFile,getTotalSize(),getDuration(),audioTracks.size)
return LocalLibraryItem(id, folderId, basePath,absolutePath, contentUrl, false,mediaType, book, localFiles, coverContentUrl, coverAbsolutePath,true,null,null,null,null) return LocalLibraryItem(id, folderId, basePath,absolutePath, contentUrl, false,mediaType, book, localFiles, coverContentUrl, coverAbsolutePath,true,null,null,null,null)
} else { } else {
val podcast = Podcast(mediaMetadata as PodcastMetadata, coverAbsolutePath, mutableListOf(), mutableListOf(), false, 0) val podcast = Podcast(mediaMetadata as PodcastMetadata, coverAbsolutePath, mutableListOf(), mutableListOf(), false, 0)

View file

@ -13,6 +13,8 @@ class LocalMediaProgress(
progress:Double, // 0 to 1 progress:Double, // 0 to 1
currentTime:Double, currentTime:Double,
isFinished:Boolean, isFinished:Boolean,
var ebookLocation:String?, // cfi tag
var ebookProgress:Double?, // 0 to 1
var lastUpdate:Long, var lastUpdate:Long,
var startedAt:Long, var startedAt:Long,
var finishedAt:Long?, var finishedAt:Long?,
@ -58,11 +60,20 @@ class LocalMediaProgress(
finishedAt = if (isFinished) lastUpdate else null finishedAt = if (isFinished) lastUpdate else null
} }
@JsonIgnore
fun updateEbookProgress(ebookLocation:String, ebookProgress:Double) {
lastUpdate = System.currentTimeMillis()
this.ebookProgress = ebookProgress
this.ebookLocation = ebookLocation
}
@JsonIgnore @JsonIgnore
fun updateFromServerMediaProgress(serverMediaProgress:MediaProgress) { fun updateFromServerMediaProgress(serverMediaProgress:MediaProgress) {
isFinished = serverMediaProgress.isFinished isFinished = serverMediaProgress.isFinished
progress = serverMediaProgress.progress progress = serverMediaProgress.progress
currentTime = serverMediaProgress.currentTime currentTime = serverMediaProgress.currentTime
ebookProgress = serverMediaProgress.ebookProgress
ebookLocation = serverMediaProgress.ebookLocation
duration = serverMediaProgress.duration duration = serverMediaProgress.duration
lastUpdate = serverMediaProgress.lastUpdate lastUpdate = serverMediaProgress.lastUpdate
finishedAt = serverMediaProgress.finishedAt finishedAt = serverMediaProgress.finishedAt

View file

@ -12,6 +12,8 @@ class MediaProgress(
progress:Double, // 0 to 1 progress:Double, // 0 to 1
currentTime:Double, currentTime:Double,
isFinished:Boolean, isFinished:Boolean,
var ebookLocation:String?, // cfi tag
var ebookProgress:Double?, // 0 to 1
var lastUpdate:Long, var lastUpdate:Long,
var startedAt:Long, var startedAt:Long,
var finishedAt:Long? var finishedAt:Long?

View file

@ -264,6 +264,6 @@ class PlaybackSession(
@JsonIgnore @JsonIgnore
fun getNewLocalMediaProgress():LocalMediaProgress { fun getNewLocalMediaProgress():LocalMediaProgress {
return LocalMediaProgress(localMediaProgressId,localLibraryItemId,localEpisodeId,getTotalDuration(),progress,currentTime,false,updatedAt,startedAt,null,serverConnectionConfigId,serverAddress,userId,libraryItemId,episodeId) return LocalMediaProgress(localMediaProgressId,localLibraryItemId,localEpisodeId,getTotalDuration(),progress,currentTime,false,null,null,updatedAt,startedAt,null,serverConnectionConfigId,serverAddress,userId,libraryItemId,episodeId)
} }
} }

View file

@ -72,7 +72,7 @@ class FolderScanner(var ctx: Context) {
Log.d(tag, "Iterating over Folder Found ${itemFolder.name} | ${itemFolder.getSimplePath(ctx)} | URI: ${itemFolder.uri}") Log.d(tag, "Iterating over Folder Found ${itemFolder.name} | ${itemFolder.getSimplePath(ctx)} | URI: ${itemFolder.uri}")
val existingItem = existingLocalLibraryItems.find { emi -> emi.id == getLocalLibraryItemId(itemFolder.id) } val existingItem = existingLocalLibraryItems.find { emi -> emi.id == getLocalLibraryItemId(itemFolder.id) }
val filesInFolder = itemFolder.search(false, DocumentFileType.FILE, arrayOf("audio/*", "image/*", "video/mp4", "application/octet-stream")) val filesInFolder = itemFolder.search(false, DocumentFileType.FILE, arrayOf("audio/*", "image/*", "video/mp4", "application/*"))
// Do not scan folders that have no media items and not an existing item already // Do not scan folders that have no media items and not an existing item already
if (existingItem != null || filesInFolder.isNotEmpty()) { if (existingItem != null || filesInFolder.isNotEmpty()) {
@ -110,6 +110,8 @@ class FolderScanner(var ctx: Context) {
var startOffset = 0.0 var startOffset = 0.0
var coverContentUrl:String? = null var coverContentUrl:String? = null
var coverAbsolutePath:String? = null var coverAbsolutePath:String? = null
var hasEBookFile = false
var newEBookFile:EBookFile? = null
val existingLocalFilesRemoved = existingLocalFiles.filter { elf -> val existingLocalFilesRemoved = existingLocalFiles.filter { elf ->
filesInFolder.find { fif -> DeviceManager.getBase64Id(fif.id) == elf.id } == null // File was not found in media item folder filesInFolder.find { fif -> DeviceManager.getBase64Id(fif.id) == elf.id } == null // File was not found in media item folder
@ -122,7 +124,6 @@ class FolderScanner(var ctx: Context) {
filesInFolder.forEach { file -> filesInFolder.forEach { file ->
val mimeType = file.mimeType ?: "" val mimeType = file.mimeType ?: ""
val filename = file.name ?: "" val filename = file.name ?: ""
val isAudio = mimeType.startsWith("audio") || mimeType == "video/mp4"
Log.d(tag, "Found $mimeType file $filename in folder $itemFolderName") Log.d(tag, "Found $mimeType file $filename in folder $itemFolderName")
val localFileId = DeviceManager.getBase64Id(file.id) val localFileId = DeviceManager.getBase64Id(file.id)
@ -132,7 +133,7 @@ class FolderScanner(var ctx: Context) {
Log.d(tag, "File attributes Id:${localFileId}|ContentUrl:${localFile.contentUrl}|isDownloadsDocument:${file.isDownloadsDocument}") Log.d(tag, "File attributes Id:${localFileId}|ContentUrl:${localFile.contentUrl}|isDownloadsDocument:${file.isDownloadsDocument}")
if (isAudio) { if (localFile.isAudioFile()) {
val audioTrackToAdd:AudioTrack? val audioTrackToAdd:AudioTrack?
val existingAudioTrack = existingAudioTracks.find { eat -> eat.localFileId == localFileId } val existingAudioTrack = existingAudioTracks.find { eat -> eat.localFileId == localFileId }
@ -174,6 +175,15 @@ class FolderScanner(var ctx: Context) {
startOffset += audioTrackToAdd.duration startOffset += audioTrackToAdd.duration
index++ index++
audioTracks.add(audioTrackToAdd) audioTracks.add(audioTrackToAdd)
} else if (localFile.isEBookFile()) {
val existingLocalFile = existingLocalFiles.find { elf -> elf.id == localFileId }
if (localFolder.mediaType == "book") {
hasEBookFile = true
if (existingLocalFile == null) {
newEBookFile = EBookFile(localFileId, null, localFile.getEBookFormat() ?: "", true, localFileId, localFile.contentUrl)
}
}
} else { } else {
val existingLocalFile = existingLocalFiles.find { elf -> elf.id == localFileId } val existingLocalFile = existingLocalFiles.find { elf -> elf.id == localFileId }
@ -198,7 +208,7 @@ class FolderScanner(var ctx: Context) {
} }
} }
if (existingItem != null && audioTracks.isEmpty()) { if (existingItem != null && audioTracks.isEmpty() && !hasEBookFile) {
Log.d(tag, "Local library item ${existingItem.media.metadata.title} no longer has audio tracks - removing item") Log.d(tag, "Local library item ${existingItem.media.metadata.title} no longer has audio tracks - removing item")
DeviceManager.dbManager.removeLocalLibraryItem(existingItem.id) DeviceManager.dbManager.removeLocalLibraryItem(existingItem.id)
return ItemScanResult.REMOVED return ItemScanResult.REMOVED
@ -210,9 +220,9 @@ class FolderScanner(var ctx: Context) {
existingItem.updateFromScan(audioTracks,localFiles) existingItem.updateFromScan(audioTracks,localFiles)
DeviceManager.dbManager.saveLocalLibraryItem(existingItem) DeviceManager.dbManager.saveLocalLibraryItem(existingItem)
return ItemScanResult.UPDATED return ItemScanResult.UPDATED
} else if (audioTracks.isNotEmpty()) { } else if (audioTracks.isNotEmpty() || newEBookFile != null) {
Log.d(tag, "Found local media item named $itemFolderName with ${audioTracks.size} tracks and ${localFiles.size} local files") Log.d(tag, "Found local media item named $itemFolderName with ${audioTracks.size} tracks and ${localFiles.size} local files")
val localMediaItem = LocalMediaItem(itemId, itemFolderName, localFolder.mediaType, localFolder.id, itemFolder.uri.toString(), itemFolder.getSimplePath(ctx), itemFolder.getBasePath(ctx), itemFolder.getAbsolutePath(ctx),audioTracks,localFiles,coverContentUrl,coverAbsolutePath) val localMediaItem = LocalMediaItem(itemId, itemFolderName, localFolder.mediaType, localFolder.id, itemFolder.uri.toString(), itemFolder.getSimplePath(ctx), itemFolder.getBasePath(ctx), itemFolder.getAbsolutePath(ctx),audioTracks,newEBookFile,localFiles,coverContentUrl,coverAbsolutePath)
val localLibraryItem = localMediaItem.getLocalLibraryItem() val localLibraryItem = localMediaItem.getLocalLibraryItem()
DeviceManager.dbManager.saveLocalLibraryItem(localLibraryItem) DeviceManager.dbManager.saveLocalLibraryItem(localLibraryItem)
return ItemScanResult.ADDED return ItemScanResult.ADDED
@ -257,7 +267,7 @@ class FolderScanner(var ctx: Context) {
// Search for files in media item folder // Search for files in media item folder
// m4b files showing as mimeType application/octet-stream on Android 10 and earlier see #154 // m4b files showing as mimeType application/octet-stream on Android 10 and earlier see #154
val filesFound = df.search(false, DocumentFileType.FILE, arrayOf("audio/*", "image/*", "video/mp4", "application/octet-stream")) val filesFound = df.search(false, DocumentFileType.FILE, arrayOf("audio/*", "image/*", "video/mp4", "application/*"))
Log.d(tag, "scanDownloadItem ${filesFound.size} files found in ${downloadItem.itemFolderPath}") Log.d(tag, "scanDownloadItem ${filesFound.size} files found in ${downloadItem.itemFolderPath}")
var localEpisodeId:String? = null var localEpisodeId:String? = null
@ -274,6 +284,7 @@ class FolderScanner(var ctx: Context) {
} }
val audioTracks:MutableList<AudioTrack> = mutableListOf() val audioTracks:MutableList<AudioTrack> = mutableListOf()
var foundEBookFile = false
filesFound.forEach { docFile -> filesFound.forEach { docFile ->
val itemPart = downloadItem.downloadItemParts.find { itemPart -> val itemPart = downloadItem.downloadItemParts.find { itemPart ->
@ -304,6 +315,16 @@ class FolderScanner(var ctx: Context) {
localEpisodeId = newEpisode.id localEpisodeId = newEpisode.id
Log.d(tag, "scanDownloadItem: Added episode to podcast ${podcastEpisode.title} ${track.title} | Track index: ${podcastEpisode.audioTrack?.index}") Log.d(tag, "scanDownloadItem: Added episode to podcast ${podcastEpisode.title} ${track.title} | Track index: ${podcastEpisode.audioTrack?.index}")
} }
} else if (itemPart.ebookFile != null) { // Ebook
foundEBookFile = true
Log.d(tag, "scanDownloadItem: Ebook file found with mimetype=${docFile.mimeType}")
val localFileId = DeviceManager.getBase64Id(docFile.id)
val localFile = LocalFile(localFileId,docFile.name,docFile.uri.toString(),docFile.getBasePath(ctx),docFile.getAbsolutePath(ctx),docFile.getSimplePath(ctx),docFile.mimeType,docFile.length())
localLibraryItem.localFiles.add(localFile)
val ebookFile = EBookFile(itemPart.ebookFile.ino, itemPart.ebookFile.metadata, itemPart.ebookFile.ebookFormat, true, localFileId, localFile.contentUrl)
(localLibraryItem.media as Book).ebookFile = ebookFile
Log.d(tag, "scanDownloadItem: Ebook file added to lli ${localFile.contentUrl}")
} else { // Cover image } else { // Cover image
val localFileId = DeviceManager.getBase64Id(docFile.id) val localFileId = DeviceManager.getBase64Id(docFile.id)
val localFile = LocalFile(localFileId,docFile.name,docFile.uri.toString(),docFile.getBasePath(ctx),docFile.getAbsolutePath(ctx),docFile.getSimplePath(ctx),docFile.mimeType,docFile.length()) val localFile = LocalFile(localFileId,docFile.name,docFile.uri.toString(),docFile.getBasePath(ctx),docFile.getAbsolutePath(ctx),docFile.getSimplePath(ctx),docFile.mimeType,docFile.length())
@ -314,8 +335,8 @@ class FolderScanner(var ctx: Context) {
} }
} }
if (audioTracks.isEmpty()) { if (audioTracks.isEmpty() && !foundEBookFile) {
Log.d(tag, "scanDownloadItem did not find any audio tracks in folder for ${downloadItem.itemFolderPath}") Log.d(tag, "scanDownloadItem did not find any audio tracks or ebook file in folder for ${downloadItem.itemFolderPath}")
return cb(null) return cb(null)
} }
@ -350,6 +371,8 @@ class FolderScanner(var ctx: Context) {
progress = mediaProgress.progress, progress = mediaProgress.progress,
currentTime = mediaProgress.currentTime, currentTime = mediaProgress.currentTime,
isFinished = false, isFinished = false,
ebookLocation = mediaProgress.ebookLocation,
ebookProgress = mediaProgress.ebookProgress,
lastUpdate = mediaProgress.lastUpdate, lastUpdate = mediaProgress.lastUpdate,
startedAt = mediaProgress.startedAt, startedAt = mediaProgress.startedAt,
finishedAt = mediaProgress.finishedAt, finishedAt = mediaProgress.finishedAt,
@ -381,7 +404,7 @@ class FolderScanner(var ctx: Context) {
var wasUpdated = false var wasUpdated = false
// Search for files in media item folder // Search for files in media item folder
val filesFound = df.search(false, DocumentFileType.FILE, arrayOf("audio/*", "image/*", "video/mp4", "application/octet-stream")) val filesFound = df.search(false, DocumentFileType.FILE, arrayOf("audio/*", "image/*", "video/mp4", "application/*"))
Log.d(tag, "scanLocalLibraryItem ${filesFound.size} files found in ${localLibraryItem.absolutePath}") Log.d(tag, "scanLocalLibraryItem ${filesFound.size} files found in ${localLibraryItem.absolutePath}")
filesFound.forEach { filesFound.forEach {
@ -427,7 +450,7 @@ class FolderScanner(var ctx: Context) {
val audioProbeResult = probeAudioFile(localFile.absolutePath) val audioProbeResult = probeAudioFile(localFile.absolutePath)
val existingTrack = existingAudioTracks.find { audioTrack -> val existingTrack = existingAudioTracks.find { audioTrack ->
audioTrack.localFileId == localFile.id audioTrack.localFileId == localFileId
} }
if (existingTrack == null) { if (existingTrack == null) {
@ -446,6 +469,16 @@ class FolderScanner(var ctx: Context) {
wasUpdated = true wasUpdated = true
} }
} else if (localFile.isEBookFile()) {
if (localLibraryItem.mediaType == "book") {
val existingEbookFile = (localLibraryItem.media as Book).ebookFile
if (existingEbookFile == null || existingEbookFile.localFileId != localFileId) {
val ebookFile = EBookFile(localFileId, null, localFile.getEBookFormat() ?: "", true, localFileId, localFile.contentUrl)
(localLibraryItem.media as Book).ebookFile = ebookFile
Log.d(tag, "scanLocalLibraryItem: Ebook file added to lli ${localFile.contentUrl}")
wasUpdated = true
}
}
} else { // Check if cover is empty } else { // Check if cover is empty
if (localLibraryItem.coverContentUrl == null) { if (localLibraryItem.coverContentUrl == null) {
Log.d(tag, "scanLocalLibraryItem setting cover for ${localLibraryItem.media.metadata.title}") Log.d(tag, "scanLocalLibraryItem setting cover for ${localLibraryItem.media.metadata.title}")

View file

@ -4,6 +4,7 @@ import android.app.DownloadManager
import android.net.Uri import android.net.Uri
import android.util.Log import android.util.Log
import com.audiobookshelf.app.data.AudioTrack import com.audiobookshelf.app.data.AudioTrack
import com.audiobookshelf.app.data.EBookFile
import com.audiobookshelf.app.data.LocalFolder import com.audiobookshelf.app.data.LocalFolder
import com.audiobookshelf.app.data.PodcastEpisode import com.audiobookshelf.app.data.PodcastEpisode
import com.audiobookshelf.app.device.DeviceManager import com.audiobookshelf.app.device.DeviceManager
@ -20,6 +21,7 @@ data class DownloadItemPart(
val localFolderName: String, val localFolderName: String,
val localFolderUrl: String, val localFolderUrl: String,
val localFolderId: String, val localFolderId: String,
val ebookFile: EBookFile?,
val audioTrack: AudioTrack?, val audioTrack: AudioTrack?,
val episode: PodcastEpisode?, val episode: PodcastEpisode?,
var completed:Boolean, var completed:Boolean,
@ -35,7 +37,7 @@ data class DownloadItemPart(
var bytesDownloaded: Long var bytesDownloaded: Long
) { ) {
companion object { companion object {
fun make(downloadItemId:String, filename:String, fileSize: Long, destinationFile: File, finalDestinationFile: File, subfolder:String, serverPath:String, localFolder: LocalFolder, audioTrack: AudioTrack?, episode: PodcastEpisode?) :DownloadItemPart { fun make(downloadItemId:String, filename:String, fileSize: Long, destinationFile: File, finalDestinationFile: File, subfolder:String, serverPath:String, localFolder: LocalFolder, ebookFile: EBookFile?, audioTrack: AudioTrack?, episode: PodcastEpisode?) :DownloadItemPart {
val destinationUri = Uri.fromFile(destinationFile) val destinationUri = Uri.fromFile(destinationFile)
val finalDestinationUri = Uri.fromFile(finalDestinationFile) val finalDestinationUri = Uri.fromFile(finalDestinationFile)
@ -53,6 +55,7 @@ data class DownloadItemPart(
localFolderName = localFolder.name, localFolderName = localFolder.name,
localFolderUrl = localFolder.contentUrl, localFolderUrl = localFolder.contentUrl,
localFolderId = localFolder.id, localFolderId = localFolder.id,
ebookFile = ebookFile,
audioTrack = audioTrack, audioTrack = audioTrack,
episode = episode, episode = episode,
completed = false, completed = false,

View file

@ -265,6 +265,8 @@ class AbsDatabase : Plugin() {
progress = mediaProgress.progress, progress = mediaProgress.progress,
currentTime = mediaProgress.currentTime, currentTime = mediaProgress.currentTime,
isFinished = mediaProgress.isFinished, isFinished = mediaProgress.isFinished,
ebookLocation = mediaProgress.ebookLocation,
ebookProgress = mediaProgress.ebookProgress,
lastUpdate = mediaProgress.lastUpdate, lastUpdate = mediaProgress.lastUpdate,
startedAt = mediaProgress.startedAt, startedAt = mediaProgress.startedAt,
finishedAt = mediaProgress.finishedAt, finishedAt = mediaProgress.finishedAt,
@ -345,6 +347,8 @@ class AbsDatabase : Plugin() {
progress = if (isFinished) 1.0 else 0.0, progress = if (isFinished) 1.0 else 0.0,
currentTime = 0.0, currentTime = 0.0,
isFinished = isFinished, isFinished = isFinished,
ebookLocation = null,
ebookProgress = null,
lastUpdate = currentTime, lastUpdate = currentTime,
startedAt = if (isFinished) currentTime else 0L, startedAt = if (isFinished) currentTime else 0L,
finishedAt = if (isFinished) currentTime else null, finishedAt = if (isFinished) currentTime else null,
@ -389,6 +393,55 @@ class AbsDatabase : Plugin() {
} }
} }
@PluginMethod
fun updateLocalEbookProgress(call:PluginCall) {
val localLibraryItemId = call.getString("localLibraryItemId", "").toString()
val ebookLocation = call.getString("ebookLocation", "").toString()
val ebookProgress = call.getDouble("ebookProgress") ?: 0.0
val localMediaProgressId = localLibraryItemId
var localMediaProgress = DeviceManager.dbManager.getLocalMediaProgress(localMediaProgressId)
if (localMediaProgress == null) {
Log.d(tag, "updateLocalEbookProgress Local Media Progress not found $localMediaProgressId - Creating new")
val localLibraryItem = DeviceManager.dbManager.getLocalLibraryItem(localLibraryItemId)
?: return call.resolve(JSObject("{\"error\":\"Library Item not found\"}"))
val book = localLibraryItem.media as Book
localMediaProgress = LocalMediaProgress(
id = localMediaProgressId,
localLibraryItemId = localLibraryItemId,
localEpisodeId = null,
duration = book.duration ?: 0.0,
progress = 0.0,
currentTime = 0.0,
isFinished = false,
ebookLocation = ebookLocation,
ebookProgress = ebookProgress,
lastUpdate = System.currentTimeMillis(),
startedAt = 0L,
finishedAt = null,
serverConnectionConfigId = localLibraryItem.serverConnectionConfigId,
serverAddress = localLibraryItem.serverAddress,
serverUserId = localLibraryItem.serverUserId,
libraryItemId = localLibraryItem.libraryItemId,
episodeId = null)
} else {
localMediaProgress.updateEbookProgress(ebookLocation, ebookProgress)
}
// Save local media progress locally
DeviceManager.dbManager.saveLocalMediaProgress(localMediaProgress)
val lmpstring = jacksonMapper.writeValueAsString(localMediaProgress)
Log.d(tag, "updateLocalEbookProgress: Local Media Progress String $lmpstring")
val jsobj = JSObject()
jsobj.put("localMediaProgress", JSObject(lmpstring))
call.resolve(jsobj)
}
@PluginMethod @PluginMethod
fun updateLocalTrackOrder(call:PluginCall) { fun updateLocalTrackOrder(call:PluginCall) {
val localLibraryItemId = call.getString("localLibraryItemId", "") ?: "" val localLibraryItemId = call.getString("localLibraryItemId", "") ?: ""

View file

@ -2,7 +2,6 @@ package com.audiobookshelf.app.plugins
import android.app.DownloadManager import android.app.DownloadManager
import android.content.Context import android.content.Context
import android.os.Build
import android.os.Environment import android.os.Environment
import android.util.Log import android.util.Log
import com.audiobookshelf.app.MainActivity import com.audiobookshelf.app.MainActivity
@ -142,6 +141,28 @@ class AbsDownloader : Plugin() {
val itemFolderPath = "${localFolder.absolutePath}/$itemSubfolder" val itemFolderPath = "${localFolder.absolutePath}/$itemSubfolder"
val downloadItem = DownloadItem(libraryItem.id, libraryItem.id, null, libraryItem.userMediaProgress,DeviceManager.serverConnectionConfig?.id ?: "", DeviceManager.serverAddress, DeviceManager.serverUserId, libraryItem.mediaType, itemFolderPath, localFolder, bookTitle, itemSubfolder, libraryItem.media, mutableListOf()) val downloadItem = DownloadItem(libraryItem.id, libraryItem.id, null, libraryItem.userMediaProgress,DeviceManager.serverConnectionConfig?.id ?: "", DeviceManager.serverAddress, DeviceManager.serverUserId, libraryItem.mediaType, itemFolderPath, localFolder, bookTitle, itemSubfolder, libraryItem.media, mutableListOf())
val book = libraryItem.media as Book
book.ebookFile?.let { ebookFile ->
val fileSize = ebookFile.metadata?.size ?: 0
val serverPath = "/s/item/${libraryItem.id}/${cleanRelPath(ebookFile.metadata?.relPath ?: "")}"
val destinationFilename = getFilenameFromRelPath(ebookFile.metadata?.relPath ?: "")
val finalDestinationFile = File("$itemFolderPath/$destinationFilename")
val destinationFile = File("$tempFolderPath/$destinationFilename")
if (destinationFile.exists()) {
Log.d(tag, "TEMP ebook file already exists, removing it from ${destinationFile.absolutePath}")
destinationFile.delete()
}
if (finalDestinationFile.exists()) {
Log.d(tag, "ebook file already exists, removing it from ${finalDestinationFile.absolutePath}")
finalDestinationFile.delete()
}
val downloadItemPart = DownloadItemPart.make(downloadItem.id, destinationFilename, fileSize, destinationFile,finalDestinationFile,itemSubfolder,serverPath,localFolder,ebookFile,null,null)
downloadItem.downloadItemParts.add(downloadItemPart)
}
// Create download item part for each audio track // Create download item part for each audio track
tracks.forEach { audioTrack -> tracks.forEach { audioTrack ->
val fileSize = audioTrack.metadata?.size ?: 0 val fileSize = audioTrack.metadata?.size ?: 0
@ -162,7 +183,7 @@ class AbsDownloader : Plugin() {
finalDestinationFile.delete() finalDestinationFile.delete()
} }
val downloadItemPart = DownloadItemPart.make(downloadItem.id, destinationFilename, fileSize, destinationFile,finalDestinationFile,itemSubfolder,serverPath,localFolder,audioTrack,null) val downloadItemPart = DownloadItemPart.make(downloadItem.id, destinationFilename, fileSize, destinationFile,finalDestinationFile,itemSubfolder,serverPath,localFolder,null,audioTrack,null)
downloadItem.downloadItemParts.add(downloadItemPart) downloadItem.downloadItemParts.add(downloadItemPart)
} }
@ -187,7 +208,7 @@ class AbsDownloader : Plugin() {
finalDestinationFile.delete() finalDestinationFile.delete()
} }
val downloadItemPart = DownloadItemPart.make(downloadItem.id, destinationFilename, coverFileSize, destinationFile,finalDestinationFile,itemSubfolder,serverPath,localFolder,null,null) val downloadItemPart = DownloadItemPart.make(downloadItem.id, destinationFilename, coverFileSize, destinationFile,finalDestinationFile,itemSubfolder,serverPath,localFolder,null,null,null)
downloadItem.downloadItemParts.add(downloadItemPart) downloadItem.downloadItemParts.add(downloadItemPart)
} }
@ -216,7 +237,7 @@ class AbsDownloader : Plugin() {
finalDestinationFile.delete() finalDestinationFile.delete()
} }
var downloadItemPart = DownloadItemPart.make(downloadItem.id, destinationFilename,fileSize, destinationFile,finalDestinationFile,podcastTitle,serverPath,localFolder,audioTrack,episode) var downloadItemPart = DownloadItemPart.make(downloadItem.id, destinationFilename,fileSize, destinationFile,finalDestinationFile,podcastTitle,serverPath,localFolder,null,audioTrack,episode)
downloadItem.downloadItemParts.add(downloadItemPart) downloadItem.downloadItemParts.add(downloadItemPart)
if (libraryItem.media.coverPath != null && libraryItem.media.coverPath?.isNotEmpty() == true) { if (libraryItem.media.coverPath != null && libraryItem.media.coverPath?.isNotEmpty() == true) {
@ -232,7 +253,7 @@ class AbsDownloader : Plugin() {
if (finalDestinationFile.exists()) { if (finalDestinationFile.exists()) {
Log.d(tag, "Podcast cover already exists - not downloading cover again") Log.d(tag, "Podcast cover already exists - not downloading cover again")
} else { } else {
downloadItemPart = DownloadItemPart.make(downloadItem.id, destinationFilename,coverFileSize,destinationFile,finalDestinationFile,podcastTitle,serverPath,localFolder,null,null) downloadItemPart = DownloadItemPart.make(downloadItem.id, destinationFilename,coverFileSize,destinationFile,finalDestinationFile,podcastTitle,serverPath,localFolder,null,null,null)
downloadItem.downloadItemParts.add(downloadItemPart) downloadItem.downloadItemParts.add(downloadItemPart)
} }
} }

View file

@ -356,6 +356,16 @@ class ApiHandler(var ctx:Context) {
Log.d(tag, "Server progress for media item id=\"${mediaProgress.mediaItemId}\" is more recent then local. Updating local current time ${localMediaProgress.currentTime} to ${mediaProgress.currentTime}") Log.d(tag, "Server progress for media item id=\"${mediaProgress.mediaItemId}\" is more recent then local. Updating local current time ${localMediaProgress.currentTime} to ${mediaProgress.currentTime}")
localMediaProgress.updateFromServerMediaProgress(mediaProgress) localMediaProgress.updateFromServerMediaProgress(mediaProgress)
MediaEventManager.syncEvent(mediaProgress, "Sync on server connection") MediaEventManager.syncEvent(mediaProgress, "Sync on server connection")
} else if (localMediaProgress.lastUpdate > mediaProgress.lastUpdate && localMediaProgress.ebookLocation != null && localMediaProgress.ebookLocation != mediaProgress.ebookLocation) {
// Patch ebook progress to server
val endpoint = "/api/me/progress/${localMediaProgress.libraryItemId}"
val updatePayload = JSObject()
updatePayload.put("ebookLocation", localMediaProgress.ebookLocation)
updatePayload.put("ebookProgress", localMediaProgress.ebookProgress)
updatePayload.put("lastUpdate", localMediaProgress.lastUpdate)
patchRequest(endpoint,updatePayload) {
Log.d(tag, "syncLocalMediaProgressForUser patched ebook progress")
}
} }
} }
} }

View file

@ -20,7 +20,8 @@ export default {
libraryItem: { libraryItem: {
type: Object, type: Object,
default: () => {} default: () => {}
} },
isLocal: Boolean
}, },
data() { data() {
return { return {
@ -41,6 +42,22 @@ export default {
libraryItemId() { libraryItemId() {
return this.libraryItem?.id return this.libraryItem?.id
}, },
localLibraryItem() {
if (this.isLocal) return this.libraryItem
return this.libraryItem.localLibraryItem || null
},
localLibraryItemId() {
return this.localLibraryItem?.id
},
serverLibraryItemId() {
if (!this.isLocal) return this.libraryItem.id
// Check if local library item is connected to the current server
if (!this.libraryItem.serverAddress || !this.libraryItem.libraryItemId) return null
if (this.$store.getters['user/getServerAddress'] === this.libraryItem.serverAddress) {
return this.libraryItem.libraryItemId
}
return null
},
playerLibraryItemId() { playerLibraryItemId() {
return this.$store.state.playerLibraryItemId return this.$store.state.playerLibraryItemId
}, },
@ -51,9 +68,15 @@ export default {
chapters() { chapters() {
return this.book ? this.book.navigation.toc : [] return this.book ? this.book.navigation.toc : []
}, },
userMediaProgress() { userItemProgress() {
if (!this.libraryItemId) return if (this.isLocal) return this.localItemProgress
return this.$store.getters['user/getUserMediaProgress'](this.libraryItemId) return this.serverItemProgress
},
localItemProgress() {
return this.$store.getters['globals/getLocalMediaProgressById'](this.localLibraryItemId)
},
serverItemProgress() {
return this.$store.getters['user/getUserMediaProgress'](this.serverLibraryItemId)
}, },
localStorageLocationsKey() { localStorageLocationsKey() {
return `ebookLocations-${this.libraryItemId}` return `ebookLocations-${this.libraryItemId}`
@ -80,10 +103,25 @@ export default {
* @param {string} payload.ebookLocation - CFI of the current location * @param {string} payload.ebookLocation - CFI of the current location
* @param {string} payload.ebookProgress - eBook Progress Percentage * @param {string} payload.ebookProgress - eBook Progress Percentage
*/ */
updateProgress(payload) { async updateProgress(payload) {
this.$axios.$patch(`/api/me/progress/${this.libraryItemId}`, payload).catch((error) => { // Update local item
console.error('EpubReader.updateProgress failed:', error) if (this.localLibraryItemId) {
}) const localPayload = {
localLibraryItemId: this.localLibraryItemId,
...payload
}
const localResponse = await this.$db.updateLocalEbookProgress(localPayload)
if (localResponse.localMediaProgress) {
this.$store.commit('globals/updateLocalMediaProgress', localResponse.localMediaProgress)
}
}
// Update server item
if (this.serverLibraryItemId) {
this.$axios.$patch(`/api/me/progress/${this.serverLibraryItemId}`, payload).catch((error) => {
console.error('EpubReader.updateProgress failed:', error)
})
}
}, },
getAllEbookLocationData() { getAllEbookLocationData() {
const locations = [] const locations = []
@ -172,7 +210,7 @@ export default {
}, },
/** @param {string} location - CFI of the new location */ /** @param {string} location - CFI of the new location */
relocated(location) { relocated(location) {
if (this.userMediaProgress?.ebookLocation === location.start.cfi) { if (this.userItemProgress?.ebookLocation === location.start.cfi) {
return return
} }
@ -189,7 +227,7 @@ export default {
} }
}, },
initEpub() { initEpub() {
this.progress = Math.round((this.userMediaProgress?.ebookProgress || 0) * 100) this.progress = Math.round((this.userItemProgress?.ebookProgress || 0) * 100)
/** @type {EpubReader} */ /** @type {EpubReader} */
const reader = this const reader = this
@ -210,7 +248,7 @@ export default {
}) })
// load saved progress // load saved progress
reader.rendition.display(this.userMediaProgress?.ebookLocation || reader.book.locations.start) reader.rendition.display(this.userItemProgress?.ebookLocation || reader.book.locations.start)
// load style // load style
reader.rendition.themes.default({ '*': { color: '#fff!important' } }) reader.rendition.themes.default({ '*': { color: '#fff!important' } })

View file

@ -5,11 +5,13 @@
<div class="flex-grow" /> <div class="flex-grow" />
<span class="material-icons text-xl text-white" @click.stop="show = false">close</span> <span class="material-icons text-xl text-white" @click.stop="show = false">close</span>
</div> </div>
<component v-if="readerComponentName" ref="readerComponent" :is="readerComponentName" :url="ebookUrl" :library-item="selectedLibraryItem" /> <component v-if="readerComponentName" ref="readerComponent" :is="readerComponentName" :url="ebookUrl" :library-item="selectedLibraryItem" :is-local="isLocal" />
</div> </div>
</template> </template>
<script> <script>
import { Capacitor } from '@capacitor/core'
export default { export default {
data() { data() {
return { return {
@ -66,11 +68,10 @@ export default {
return this.selectedLibraryItem ? this.selectedLibraryItem.libraryId : null return this.selectedLibraryItem ? this.selectedLibraryItem.libraryId : null
}, },
ebookFile() { ebookFile() {
return this.media ? this.media.ebookFile : null return this.media?.ebookFile || null
}, },
ebookFormat() { ebookFormat() {
if (!this.ebookFile) return null return this.ebookFile?.ebookFormat || null
return this.ebookFile.ebookFormat
}, },
ebookType() { ebookType() {
if (this.isMobi) return 'mobi' if (this.isMobi) return 'mobi'
@ -91,8 +92,17 @@ export default {
isComic() { isComic() {
return this.ebookFormat == 'cbz' || this.ebookFormat == 'cbr' return this.ebookFormat == 'cbz' || this.ebookFormat == 'cbr'
}, },
isLocal() {
return !!this.ebookFile?.isLocal
},
localContentUrl() {
return this.ebookFile?.contentUrl
},
ebookUrl() { ebookUrl() {
if (!this.ebookFile) return null if (!this.ebookFile) return null
if (this.localContentUrl) {
return Capacitor.convertFileSrc(this.localContentUrl)
}
let filepath = '' let filepath = ''
if (this.selectedLibraryItem.isFile) { if (this.selectedLibraryItem.isFile) {
filepath = this.$encodeUriPath(this.ebookFile.metadata.filename) filepath = this.$encodeUriPath(this.ebookFile.metadata.filename)

View file

@ -278,11 +278,7 @@ export default {
this.$store.commit('globals/updateLocalMediaProgress', localMediaProgress) this.$store.commit('globals/updateLocalMediaProgress', localMediaProgress)
} }
var lmp = this.$store.getters['globals/getLocalMediaProgressById'](this.libraryItemId, this.episode.id) if (payload.server) {
console.log('toggleFinished Check LMP', this.libraryItemId, this.episode.id, JSON.stringify(lmp))
var serverUpdated = payload.server
if (serverUpdated) {
this.$toast.success(`Local & Server Item marked as ${isFinished ? 'Finished' : 'Not Finished'}`) this.$toast.success(`Local & Server Item marked as ${isFinished ? 'Finished' : 'Not Finished'}`)
} else { } else {
this.$toast.success(`Local Item marked as ${isFinished ? 'Finished' : 'Not Finished'}`) this.$toast.success(`Local Item marked as ${isFinished ? 'Finished' : 'Not Finished'}`)

View file

@ -284,11 +284,7 @@ export default {
this.$store.commit('globals/updateLocalMediaProgress', localMediaProgress) this.$store.commit('globals/updateLocalMediaProgress', localMediaProgress)
} }
var lmp = this.$store.getters['globals/getLocalMediaProgressById'](this.libraryItemId, this.episode.id) if (payload.server) {
console.log('toggleFinished Check LMP', this.libraryItemId, this.episode.id, JSON.stringify(lmp))
var serverUpdated = payload.server
if (serverUpdated) {
this.$toast.success(`Local & Server Item marked as ${isFinished ? 'Finished' : 'Not Finished'}`) this.$toast.success(`Local & Server Item marked as ${isFinished ? 'Finished' : 'Not Finished'}`)
} else { } else {
this.$toast.success(`Local Item marked as ${isFinished ? 'Finished' : 'Not Finished'}`) this.$toast.success(`Local Item marked as ${isFinished ? 'Finished' : 'Not Finished'}`)

View file

@ -357,7 +357,7 @@ export default {
}, },
showDownload() { showDownload() {
if (this.isPodcast || this.hasLocal) return false if (this.isPodcast || this.hasLocal) return false
return this.user && this.userCanDownload && this.showPlay return this.user && this.userCanDownload && (this.showPlay || (this.showRead && !this.isIos))
}, },
ebookFile() { ebookFile() {
return this.media.ebookFile return this.media.ebookFile
@ -481,7 +481,12 @@ export default {
this.showMoreMenu = true this.showMoreMenu = true
}, },
readBook() { readBook() {
this.$store.commit('openReader', this.libraryItem) if (this.localLibraryItem?.media?.ebookFile) {
// Has local ebook file
this.$store.commit('openReader', this.localLibraryItem)
} else {
this.$store.commit('openReader', this.libraryItem)
}
}, },
playAtTimestamp(seconds) { playAtTimestamp(seconds) {
this.play(seconds) this.play(seconds)
@ -607,9 +612,6 @@ export default {
if (this.downloadItem) { if (this.downloadItem) {
return return
} }
if (!this.numTracks) {
return
}
await this.$hapticsImpact() await this.$hapticsImpact()
if (this.isIos) { if (this.isIos) {
// no local folders on iOS // no local folders on iOS
@ -647,7 +649,14 @@ export default {
console.log('Local folder', JSON.stringify(localFolder)) console.log('Local folder', JSON.stringify(localFolder))
var startDownloadMessage = `Start download for "${this.title}" with ${this.numTracks} audio track${this.numTracks == 1 ? '' : 's'} to folder ${localFolder.name}?` let startDownloadMessage = `Start download for "${this.title}" with ${this.numTracks} audio track${this.numTracks == 1 ? '' : 's'} to folder ${localFolder.name}?`
if (!this.isIos && this.showRead) {
if (this.numTracks > 0) {
startDownloadMessage = `Start download for "${this.title}" with ${this.numTracks} audio track${this.numTracks == 1 ? '' : 's'} and ebook file to folder ${localFolder.name}?`
} else {
startDownloadMessage = `Start download for "${this.title}" with ebook file to folder ${localFolder.name}?`
}
}
const { value } = await Dialog.confirm({ const { value } = await Dialog.confirm({
title: 'Confirm', title: 'Confirm',
message: startDownloadMessage message: startDownloadMessage
@ -704,11 +713,7 @@ export default {
this.$store.commit('globals/updateLocalMediaProgress', localMediaProgress) this.$store.commit('globals/updateLocalMediaProgress', localMediaProgress)
} }
var lmp = this.$store.getters['globals/getLocalMediaProgressById'](this.libraryItemId) if (payload.server) {
console.log('toggleFinished Check LMP', this.libraryItemId, JSON.stringify(lmp))
var serverUpdated = payload.server
if (serverUpdated) {
this.$toast.success(`Local & Server Item marked as ${isFinished ? 'Finished' : 'Not Finished'}`) this.$toast.success(`Local & Server Item marked as ${isFinished ? 'Finished' : 'Not Finished'}`)
} else { } else {
this.$toast.success(`Local Item marked as ${isFinished ? 'Finished' : 'Not Finished'}`) this.$toast.success(`Local Item marked as ${isFinished ? 'Finished' : 'Not Finished'}`)

View file

@ -86,11 +86,16 @@ class DbService {
return AbsDatabase.updateLocalTrackOrder(payload) return AbsDatabase.updateLocalTrackOrder(payload)
} }
// input: { localMediaProgressId:String, isFinished:Boolean } // input: { localLibraryItemId:String, localEpisodeId:String, isFinished:Boolean }
updateLocalMediaProgressFinished(payload) { updateLocalMediaProgressFinished(payload) {
return AbsDatabase.updateLocalMediaProgressFinished(payload) return AbsDatabase.updateLocalMediaProgressFinished(payload)
} }
// input: { localLibraryItemId:String, ebookLocation:String, ebookProgress:Double }
updateLocalEbookProgress(payload) {
return AbsDatabase.updateLocalEbookProgress(payload)
}
updateDeviceSettings(payload) { updateDeviceSettings(payload) {
return AbsDatabase.updateDeviceSettings(payload) return AbsDatabase.updateDeviceSettings(payload)
} }