mirror of
https://github.com/advplyr/audiobookshelf-app.git
synced 2025-07-13 15:34:50 +02:00
Update folder scanner and db to store LocalLibraryItem objects instead of LocalMediaItem objects, some ui fixes and audio player service binding fix
This commit is contained in:
parent
77ef0c119b
commit
12de187b7a
22 changed files with 248 additions and 158 deletions
|
@ -71,7 +71,7 @@
|
|||
<service
|
||||
android:exported="true"
|
||||
android:enabled="true"
|
||||
android:name=".PlayerNotificationService">
|
||||
android:name=".player.PlayerNotificationService">
|
||||
<intent-filter>
|
||||
<action android:name="android.media.browse.MediaBrowserService"/>
|
||||
</intent-filter>
|
||||
|
|
|
@ -10,7 +10,6 @@ import androidx.core.app.ActivityCompat
|
|||
import com.anggrayudi.storage.SimpleStorage
|
||||
import com.anggrayudi.storage.SimpleStorageHelper
|
||||
import com.audiobookshelf.app.data.AbsDatabase
|
||||
import com.audiobookshelf.app.data.DbManager
|
||||
import com.audiobookshelf.app.player.PlayerNotificationService
|
||||
import com.audiobookshelf.app.plugins.AbsDownloader
|
||||
import com.audiobookshelf.app.plugins.AbsAudioPlayer
|
||||
|
@ -18,7 +17,6 @@ import com.audiobookshelf.app.plugins.AbsFileSystem
|
|||
import com.getcapacitor.BridgeActivity
|
||||
import io.paperdb.Paper
|
||||
|
||||
|
||||
class MainActivity : BridgeActivity() {
|
||||
private val tag = "MainActivity"
|
||||
|
||||
|
@ -87,6 +85,7 @@ class MainActivity : BridgeActivity() {
|
|||
|
||||
override fun onPostCreate(savedInstanceState: Bundle?) {
|
||||
super.onPostCreate(savedInstanceState)
|
||||
Log.d(tag, "onPostCreate MainActivity")
|
||||
|
||||
mConnection = object : ServiceConnection {
|
||||
override fun onServiceDisconnected(name: ComponentName) {
|
||||
|
@ -97,7 +96,6 @@ class MainActivity : BridgeActivity() {
|
|||
override fun onServiceConnected(name: ComponentName, service: IBinder) {
|
||||
Log.d(tag, "Service Connected $name")
|
||||
|
||||
|
||||
mBounded = true
|
||||
val mLocalBinder = service as PlayerNotificationService.LocalBinder
|
||||
foregroundService = mLocalBinder.getService()
|
||||
|
@ -109,8 +107,10 @@ class MainActivity : BridgeActivity() {
|
|||
}
|
||||
}
|
||||
|
||||
val startIntent = Intent(this, PlayerNotificationService::class.java)
|
||||
bindService(startIntent, mConnection as ServiceConnection, Context.BIND_AUTO_CREATE);
|
||||
Intent(this, PlayerNotificationService::class.java).also { intent ->
|
||||
Log.d(tag, "Binding PlayerNotificationService")
|
||||
bindService(intent, mConnection, Context.BIND_AUTO_CREATE)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
|
|
@ -30,28 +30,68 @@ data class LibraryItem(
|
|||
JsonSubTypes.Type(Book::class),
|
||||
JsonSubTypes.Type(Podcast::class)
|
||||
)
|
||||
open class MediaType {}
|
||||
open class MediaType(var metadata:MediaTypeMetadata, var coverPath:String?) {
|
||||
@JsonIgnore
|
||||
open fun getAudioTracks():List<AudioTrack> { return mutableListOf() }
|
||||
@JsonIgnore
|
||||
open fun setAudioTracks(audioTracks:List<AudioTrack>) { }
|
||||
}
|
||||
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
data class Podcast(
|
||||
var metadata:PodcastMetadata,
|
||||
var coverPath:String?,
|
||||
class Podcast(
|
||||
metadata:PodcastMetadata,
|
||||
coverPath:String?,
|
||||
var tags:MutableList<String>,
|
||||
var episodes:MutableList<PodcastEpisode>,
|
||||
var autoDownloadEpisodes:Boolean
|
||||
) : MediaType()
|
||||
) : MediaType(metadata, coverPath) {
|
||||
@JsonIgnore
|
||||
override fun getAudioTracks():List<AudioTrack> {
|
||||
var tracks = episodes.map { it.audioTrack }
|
||||
return tracks.filterNotNull()
|
||||
}
|
||||
@JsonIgnore
|
||||
override fun setAudioTracks(audioTracks:List<AudioTrack>) {
|
||||
// Remove episodes no longer there in tracks
|
||||
episodes = episodes.filter { ep ->
|
||||
audioTracks.find { it.localFileId == ep.audioTrack?.localFileId } != null
|
||||
} as MutableList<PodcastEpisode>
|
||||
// Add new episodes
|
||||
audioTracks.forEach { at ->
|
||||
if (episodes.find{ it.audioTrack?.localFileId == at.localFileId } == null) {
|
||||
var newEpisode = PodcastEpisode("local_" + at.localFileId,episodes.size + 1,null,null,at.title,null,null,null,at)
|
||||
episodes.add(newEpisode)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
data class Book(
|
||||
var metadata:BookMetadata,
|
||||
var coverPath:String?,
|
||||
class Book(
|
||||
metadata:BookMetadata,
|
||||
coverPath:String?,
|
||||
var tags:List<String>,
|
||||
var audioFiles:List<AudioFile>,
|
||||
var chapters:List<BookChapter>,
|
||||
var tracks:List<AudioTrack>?,
|
||||
var size:Long?,
|
||||
var duration:Double?
|
||||
) : MediaType()
|
||||
) : MediaType(metadata, coverPath) {
|
||||
@JsonIgnore
|
||||
override fun getAudioTracks():List<AudioTrack> {
|
||||
return tracks ?: mutableListOf()
|
||||
}
|
||||
@JsonIgnore
|
||||
override fun setAudioTracks(audioTracks:List<AudioTrack>) {
|
||||
tracks = audioTracks
|
||||
|
||||
var totalDuration = 0.0
|
||||
tracks?.forEach {
|
||||
totalDuration += it.duration
|
||||
}
|
||||
duration = totalDuration
|
||||
}
|
||||
}
|
||||
|
||||
// This auto-detects whether it is a Book or Podcast
|
||||
@JsonTypeInfo(use=JsonTypeInfo.Id.DEDUCTION)
|
||||
|
@ -59,11 +99,11 @@ data class Book(
|
|||
JsonSubTypes.Type(BookMetadata::class),
|
||||
JsonSubTypes.Type(PodcastMetadata::class)
|
||||
)
|
||||
open class MediaTypeMetadata {}
|
||||
open class MediaTypeMetadata(var title:String) {}
|
||||
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
data class BookMetadata(
|
||||
var title:String,
|
||||
class BookMetadata(
|
||||
title:String,
|
||||
var subtitle:String?,
|
||||
var authors:MutableList<Author>,
|
||||
var narrators:MutableList<String>,
|
||||
|
@ -81,15 +121,15 @@ data class BookMetadata(
|
|||
var authorNameLF:String?,
|
||||
var narratorName:String?,
|
||||
var seriesName:String?
|
||||
) : MediaTypeMetadata()
|
||||
) : MediaTypeMetadata(title)
|
||||
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
data class PodcastMetadata(
|
||||
var title:String,
|
||||
class PodcastMetadata(
|
||||
title:String,
|
||||
var author:String?,
|
||||
var feedUrl:String?,
|
||||
var genres:MutableList<String>
|
||||
) : MediaTypeMetadata()
|
||||
) : MediaTypeMetadata(title)
|
||||
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
data class Author(
|
||||
|
@ -107,7 +147,8 @@ data class PodcastEpisode(
|
|||
var title:String?,
|
||||
var subtitle:String?,
|
||||
var description:String?,
|
||||
var audioFile:AudioFile
|
||||
var audioFile:AudioFile?,
|
||||
var audioTrack:AudioTrack?
|
||||
)
|
||||
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
|
|
|
@ -14,11 +14,11 @@ class DbManager {
|
|||
Paper.book("device").write("data", deviceData)
|
||||
}
|
||||
|
||||
fun getLocalMediaItems():MutableList<LocalMediaItem> {
|
||||
var localMediaItems:MutableList<LocalMediaItem> = mutableListOf()
|
||||
Paper.book("localMediaItems").allKeys.forEach {
|
||||
var localMediaItem:LocalMediaItem? = Paper.book("localMediaItems").read(it)
|
||||
if (localMediaItem != null) {
|
||||
fun getLocalLibraryItems():MutableList<LocalLibraryItem> {
|
||||
var localLibraryItems:MutableList<LocalLibraryItem> = mutableListOf()
|
||||
Paper.book("localLibraryItems").allKeys.forEach {
|
||||
var localLibraryItem:LocalLibraryItem? = Paper.book("localLibraryItems").read(it)
|
||||
if (localLibraryItem != null) {
|
||||
// TODO: Check to make sure all file paths exist
|
||||
// if (localMediaItem.coverContentUrl != null) {
|
||||
// var file = DocumentFile.fromSingleUri(ctx)
|
||||
|
@ -29,31 +29,31 @@ class DbManager {
|
|||
// localMediaItems.add(localMediaItem)
|
||||
// }
|
||||
// } else {
|
||||
localMediaItems.add(localMediaItem)
|
||||
localLibraryItems.add(localLibraryItem)
|
||||
// }
|
||||
}
|
||||
}
|
||||
return localMediaItems
|
||||
return localLibraryItems
|
||||
}
|
||||
|
||||
fun getLocalMediaItemsInFolder(folderId:String):List<LocalMediaItem> {
|
||||
var localMediaItems = getLocalMediaItems()
|
||||
return localMediaItems.filter {
|
||||
fun getLocalLibraryItemsInFolder(folderId:String):List<LocalLibraryItem> {
|
||||
var localLibraryItems = getLocalLibraryItems()
|
||||
return localLibraryItems.filter {
|
||||
it.folderId == folderId
|
||||
}
|
||||
}
|
||||
|
||||
fun getLocalMediaItem(localMediaItemId:String):LocalMediaItem? {
|
||||
return Paper.book("localMediaItems").read(localMediaItemId)
|
||||
fun getLocalLibraryItem(localLibraryItemId:String):LocalLibraryItem? {
|
||||
return Paper.book("localLibraryItems").read(localLibraryItemId)
|
||||
}
|
||||
|
||||
fun removeLocalMediaItem(localMediaItemId:String) {
|
||||
Paper.book("localMediaItems").delete(localMediaItemId)
|
||||
fun removeLocalLibraryItem(localLibraryItemId:String) {
|
||||
Paper.book("localLibraryItems").delete(localLibraryItemId)
|
||||
}
|
||||
|
||||
fun saveLocalMediaItems(localMediaItems:List<LocalMediaItem>) {
|
||||
localMediaItems.map {
|
||||
Paper.book("localMediaItems").write(it.id, it)
|
||||
fun saveLocalLibraryItems(localLibraryItems:List<LocalLibraryItem>) {
|
||||
localLibraryItems.map {
|
||||
Paper.book("localLibraryItems").write(it.id, it)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -77,9 +77,9 @@ class DbManager {
|
|||
}
|
||||
|
||||
fun removeLocalFolder(folderId:String) {
|
||||
var localMediaItems = getLocalMediaItemsInFolder(folderId)
|
||||
localMediaItems.forEach {
|
||||
Paper.book("localMediaItems").delete(it.id)
|
||||
var localLibraryItems = getLocalLibraryItemsInFolder(folderId)
|
||||
localLibraryItems.forEach {
|
||||
Paper.book("localLibraryItems").delete(it.id)
|
||||
}
|
||||
Paper.book("localFolders").delete(folderId)
|
||||
}
|
||||
|
|
|
@ -22,14 +22,54 @@ data class DeviceData(
|
|||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
data class LocalLibraryItem(
|
||||
var id:String,
|
||||
var libraryItemId:String?,
|
||||
var folderId:String,
|
||||
var absolutePath:String,
|
||||
var isInvalid:Boolean,
|
||||
var mediaType:String,
|
||||
var media:MediaType,
|
||||
var localFiles:MutableList<LocalFile>,
|
||||
var coverContentUrl:String?,
|
||||
var coverAbsolutePath:String?,
|
||||
var isLocal:Boolean
|
||||
)
|
||||
) {
|
||||
@JsonIgnore
|
||||
fun getDuration():Double {
|
||||
var total = 0.0
|
||||
var audioTracks = media.getAudioTracks()
|
||||
audioTracks.forEach{ total += it.duration }
|
||||
return total
|
||||
}
|
||||
|
||||
@JsonIgnore
|
||||
fun updateFromScan(audioTracks:List<AudioTrack>, _localFiles:MutableList<LocalFile>) {
|
||||
media.setAudioTracks(audioTracks)
|
||||
localFiles = _localFiles
|
||||
|
||||
if (coverContentUrl != null) {
|
||||
if (localFiles.find { it.contentUrl == coverContentUrl } == null) {
|
||||
// Cover was removed
|
||||
coverContentUrl = null
|
||||
coverAbsolutePath = null
|
||||
media.coverPath = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@JsonIgnore
|
||||
fun getPlaybackSession():PlaybackSession {
|
||||
var sessionId = "play-${UUID.randomUUID()}"
|
||||
|
||||
var mediaMetadata = media.metadata
|
||||
var chapters = if (mediaType == "book") (media as Book).chapters else mutableListOf()
|
||||
var authorName = "Unknown"
|
||||
if (mediaType == "book") {
|
||||
var bookMetadata = mediaMetadata as BookMetadata
|
||||
authorName = bookMetadata?.authorName ?: "Unknown"
|
||||
}
|
||||
return PlaybackSession(sessionId,null,null,null, mediaType, mediaMetadata, chapters, mediaMetadata.title, authorName,null,getDuration(),PLAYMETHOD_LOCAL, media.getAudioTracks() as MutableList<AudioTrack>,0.0,null,this,null,null)
|
||||
}
|
||||
}
|
||||
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
data class LocalMediaItem(
|
||||
|
@ -69,20 +109,6 @@ data class LocalMediaItem(
|
|||
}
|
||||
}
|
||||
|
||||
@JsonIgnore
|
||||
fun getPlaybackSession():PlaybackSession {
|
||||
var sessionId = "play-${UUID.randomUUID()}"
|
||||
|
||||
var mediaMetadata = getMediaMetadata()
|
||||
var chapters = getAudiobookChapters()
|
||||
var authorName = "Unknown"
|
||||
if (mediaType == "book") {
|
||||
var bookMetadata = mediaMetadata as BookMetadata
|
||||
authorName = bookMetadata?.authorName ?: "Unknown"
|
||||
}
|
||||
return PlaybackSession(sessionId,null,null,null, mediaType, mediaMetadata, chapters, name, authorName,null,getDuration(),PLAYMETHOD_LOCAL,audioTracks,0.0,null,this,null,null)
|
||||
}
|
||||
|
||||
@JsonIgnore
|
||||
fun getAudiobookChapters():List<BookChapter> {
|
||||
if (mediaType != "book" || audioTracks.isEmpty()) return mutableListOf()
|
||||
|
@ -98,11 +124,11 @@ data class LocalMediaItem(
|
|||
var mediaMetadata = getMediaMetadata()
|
||||
if (mediaType == "book") {
|
||||
var chapters = getAudiobookChapters()
|
||||
var book = Book(mediaMetadata as BookMetadata, coverContentUrl, mutableListOf(), mutableListOf(), chapters,audioTracks,getTotalSize(),getDuration())
|
||||
return LocalLibraryItem(id, folderId, absolutePath, false,mediaType, book, localFiles, true)
|
||||
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)
|
||||
} else {
|
||||
var podcast = Podcast(mediaMetadata as PodcastMetadata, coverContentUrl, mutableListOf(), mutableListOf(), false)
|
||||
return LocalLibraryItem(id, folderId, absolutePath, false, mediaType, podcast,localFiles,true)
|
||||
var podcast = Podcast(mediaMetadata as PodcastMetadata, coverAbsolutePath, mutableListOf(), mutableListOf(), false)
|
||||
return LocalLibraryItem(id, null, folderId, absolutePath, false, mediaType, podcast,localFiles,coverContentUrl, coverAbsolutePath, true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -6,5 +6,5 @@ data class FolderScanResult(
|
|||
var itemsRemoved:Int,
|
||||
var itemsUpToDate:Int,
|
||||
val localFolder:LocalFolder,
|
||||
val localMediaItems:List<LocalMediaItem>,
|
||||
val localLibraryItems:List<LocalLibraryItem>,
|
||||
)
|
||||
|
|
|
@ -32,7 +32,7 @@ class PlaybackSession(
|
|||
var audioTracks:MutableList<AudioTrack>,
|
||||
var currentTime:Double,
|
||||
var libraryItem:LibraryItem?,
|
||||
var localMediaItem:LocalMediaItem?,
|
||||
var localLibraryItem:LocalLibraryItem?,
|
||||
var serverUrl:String?,
|
||||
var token:String?
|
||||
) {
|
||||
|
@ -74,7 +74,7 @@ class PlaybackSession(
|
|||
|
||||
@JsonIgnore
|
||||
fun getCoverUri(): Uri {
|
||||
if (localMediaItem?.coverContentUrl != null) return Uri.parse(localMediaItem?.coverContentUrl) ?: Uri.parse("android.resource://com.audiobookshelf.app/" + R.drawable.icon)
|
||||
if (localLibraryItem?.coverContentUrl != null) return Uri.parse(localLibraryItem?.coverContentUrl) ?: Uri.parse("android.resource://com.audiobookshelf.app/" + R.drawable.icon)
|
||||
|
||||
if (coverPath == null) return Uri.parse("android.resource://com.audiobookshelf.app/" + R.drawable.icon)
|
||||
return Uri.parse("$serverUrl/api/items/$libraryItemId/cover?token=$token")
|
||||
|
|
|
@ -15,6 +15,10 @@ import com.fasterxml.jackson.module.kotlin.readValue
|
|||
class FolderScanner(var ctx: Context) {
|
||||
private val tag = "FolderScanner"
|
||||
|
||||
private fun getLocalLibraryItemId(mediaItemId:String):String {
|
||||
return "local_" + DeviceManager.getBase64Id(mediaItemId)
|
||||
}
|
||||
|
||||
// TODO: CLEAN this monster! Divide into bite-size methods
|
||||
fun scanForMediaItems(localFolder:LocalFolder, forceAudioProbe:Boolean):FolderScanResult? {
|
||||
FFmpegKitConfig.enableLogCallback { log ->
|
||||
|
@ -38,32 +42,32 @@ class FolderScanner(var ctx: Context) {
|
|||
// Search for files in media item folder
|
||||
var foldersFound = df.search(false, DocumentFileType.FOLDER)
|
||||
|
||||
// Match folders found with media items already saved in db
|
||||
var existingMediaItems = DeviceManager.dbManager.getLocalMediaItemsInFolder(localFolder.id)
|
||||
// Match folders found with local library items already saved in db
|
||||
var existingLocalLibraryItems = DeviceManager.dbManager.getLocalLibraryItemsInFolder(localFolder.id)
|
||||
|
||||
// Remove existing items no longer there
|
||||
existingMediaItems = existingMediaItems.filter { lmi ->
|
||||
var fileFound = foldersFound.find { f -> lmi.id == DeviceManager.getBase64Id(f.id) }
|
||||
existingLocalLibraryItems = existingLocalLibraryItems.filter { lli ->
|
||||
var fileFound = foldersFound.find { f -> lli.id == getLocalLibraryItemId(f.id) }
|
||||
if (fileFound == null) {
|
||||
Log.d(tag, "Existing media item is no longer in file system ${lmi.name}")
|
||||
DeviceManager.dbManager.removeLocalMediaItem(lmi.id)
|
||||
Log.d(tag, "Existing local library item is no longer in file system ${lli.media.metadata.title}")
|
||||
DeviceManager.dbManager.removeLocalLibraryItem(lli.id)
|
||||
mediaItemsRemoved++
|
||||
}
|
||||
fileFound != null
|
||||
}
|
||||
|
||||
var mediaItems = mutableListOf<LocalMediaItem>()
|
||||
var localLibraryItems = mutableListOf<LocalLibraryItem>()
|
||||
|
||||
foldersFound.forEach {
|
||||
Log.d(tag, "Iterating over Folder Found ${it.name} | ${it.getSimplePath(ctx)} | URI: ${it.uri}")
|
||||
foldersFound.forEach { itemFolder ->
|
||||
Log.d(tag, "Iterating over Folder Found ${itemFolder.name} | ${itemFolder.getSimplePath(ctx)} | URI: ${itemFolder.uri}")
|
||||
|
||||
var itemFolderName = it.name ?: ""
|
||||
var itemId = "local_" + DeviceManager.getBase64Id(it.id)
|
||||
var itemFolderName = itemFolder.name ?: ""
|
||||
var itemId = getLocalLibraryItemId(itemFolder.id)
|
||||
|
||||
var existingMediaItem = existingMediaItems.find { emi -> emi.id == itemId }
|
||||
var existingLocalFiles = existingMediaItem?.localFiles ?: mutableListOf()
|
||||
var existingAudioTracks = existingMediaItem?.audioTracks ?: mutableListOf()
|
||||
var isNewOrUpdated = existingMediaItem == null
|
||||
var existingItem = existingLocalLibraryItems.find { emi -> emi.id == itemId }
|
||||
var existingLocalFiles = existingItem?.localFiles ?: mutableListOf()
|
||||
var existingAudioTracks = existingItem?.media?.getAudioTracks() ?: mutableListOf()
|
||||
var isNewOrUpdated = existingItem == null
|
||||
|
||||
var audioTracks = mutableListOf<AudioTrack>()
|
||||
var localFiles = mutableListOf<LocalFile>()
|
||||
|
@ -72,13 +76,14 @@ class FolderScanner(var ctx: Context) {
|
|||
var coverContentUrl:String? = null
|
||||
var coverAbsolutePath:String? = null
|
||||
|
||||
var filesInFolder = it.search(false, DocumentFileType.FILE, arrayOf("audio/*", "image/*"))
|
||||
var filesInFolder = itemFolder.search(false, DocumentFileType.FILE, arrayOf("audio/*", "image/*"))
|
||||
|
||||
|
||||
var existingLocalFilesRemoved = existingLocalFiles.filter { elf ->
|
||||
filesInFolder.find { fif -> DeviceManager.getBase64Id(fif.id) == elf.id } == null // File was not found in media item folder
|
||||
}
|
||||
if (existingLocalFilesRemoved.isNotEmpty()) {
|
||||
Log.d(tag, "${existingLocalFilesRemoved.size} Local files were removed from local media item ${existingMediaItem?.name}")
|
||||
Log.d(tag, "${existingLocalFilesRemoved.size} Local files were removed from local media item ${existingItem?.media?.metadata?.title}")
|
||||
isNewOrUpdated = true
|
||||
}
|
||||
|
||||
|
@ -147,9 +152,12 @@ class FolderScanner(var ctx: Context) {
|
|||
if (existingLocalFile == null) {
|
||||
isNewOrUpdated = true
|
||||
}
|
||||
if (existingMediaItem != null && existingMediaItem.coverContentUrl == null) {
|
||||
if (existingItem != null && existingItem.coverContentUrl == null) {
|
||||
// Existing media item did not have a cover - cover found on scan
|
||||
isNewOrUpdated = true
|
||||
existingItem.coverAbsolutePath = localFile.absolutePath
|
||||
existingItem.coverContentUrl = localFile.contentUrl
|
||||
existingItem.media.coverPath = localFile.absolutePath
|
||||
}
|
||||
|
||||
// First image file use as cover path
|
||||
|
@ -160,30 +168,36 @@ class FolderScanner(var ctx: Context) {
|
|||
}
|
||||
}
|
||||
|
||||
if (existingMediaItem != null && audioTracks.isEmpty()) {
|
||||
Log.d(tag, "Local media item ${existingMediaItem.name} no longer has audio tracks - removing item")
|
||||
DeviceManager.dbManager.removeLocalMediaItem(existingMediaItem.id)
|
||||
if (existingItem != null && audioTracks.isEmpty()) {
|
||||
Log.d(tag, "Local library item ${existingItem.media.metadata.title} no longer has audio tracks - removing item")
|
||||
DeviceManager.dbManager.removeLocalLibraryItem(existingItem.id)
|
||||
mediaItemsRemoved++
|
||||
} else if (existingMediaItem != null && !isNewOrUpdated) {
|
||||
Log.d(tag, "Local media item ${existingMediaItem.name} has no updates")
|
||||
} else if (existingItem != null && !isNewOrUpdated) {
|
||||
Log.d(tag, "Local library item ${existingItem.media.metadata.title} has no updates")
|
||||
mediaItemsUpToDate++
|
||||
} else if (audioTracks.isNotEmpty()) {
|
||||
if (existingMediaItem != null) mediaItemsUpdated++
|
||||
else mediaItemsAdded++
|
||||
} else if (existingItem != null) {
|
||||
Log.d(tag, "Updating local library item ${existingItem.media.metadata.title}")
|
||||
mediaItemsUpdated++
|
||||
|
||||
existingItem.updateFromScan(audioTracks,localFiles)
|
||||
localLibraryItems.add(existingItem)
|
||||
} else if (audioTracks.isNotEmpty()) {
|
||||
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,coverContentUrl,coverAbsolutePath)
|
||||
mediaItems.add(localMediaItem)
|
||||
mediaItemsAdded++
|
||||
|
||||
var localMediaItem = LocalMediaItem(itemId, itemFolderName, localFolder.mediaType, localFolder.id, itemFolder.uri.toString(), itemFolder.getSimplePath(ctx), itemFolder.getAbsolutePath(ctx),audioTracks,localFiles,coverContentUrl,coverAbsolutePath)
|
||||
var localLibraryItem = localMediaItem.getLocalLibraryItem()
|
||||
localLibraryItems.add(localLibraryItem)
|
||||
}
|
||||
}
|
||||
|
||||
Log.d(tag, "Folder $${localFolder.name} scan Results: $mediaItemsAdded Added | $mediaItemsUpdated Updated | $mediaItemsRemoved Removed | $mediaItemsUpToDate Up-to-date")
|
||||
|
||||
return if (mediaItems.isNotEmpty()) {
|
||||
DeviceManager.dbManager.saveLocalMediaItems(mediaItems)
|
||||
return if (localLibraryItems.isNotEmpty()) {
|
||||
DeviceManager.dbManager.saveLocalLibraryItems(localLibraryItems)
|
||||
|
||||
var folderMediaItems = DeviceManager.dbManager.getLocalMediaItemsInFolder(localFolder.id) // Get all local media items
|
||||
FolderScanResult(mediaItemsAdded, mediaItemsUpdated, mediaItemsRemoved, mediaItemsUpToDate, localFolder, folderMediaItems)
|
||||
var folderLibraryItems = DeviceManager.dbManager.getLocalLibraryItemsInFolder(localFolder.id) // Get all local media items
|
||||
FolderScanResult(mediaItemsAdded, mediaItemsUpdated, mediaItemsRemoved, mediaItemsUpToDate, localFolder, folderLibraryItems)
|
||||
} else {
|
||||
Log.d(tag, "No Media Items to save")
|
||||
FolderScanResult(mediaItemsAdded, mediaItemsUpdated, mediaItemsRemoved, mediaItemsUpToDate, localFolder, mutableListOf())
|
||||
|
|
|
@ -133,8 +133,8 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
|||
}
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
Log.d(tag, "onStartCommand $startId")
|
||||
isStarted = true
|
||||
Log.d(tag, "onStartCommand $startId")
|
||||
|
||||
return START_STICKY
|
||||
}
|
||||
|
|
|
@ -1,8 +1,10 @@
|
|||
package com.audiobookshelf.app.plugins
|
||||
|
||||
import android.content.Intent
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.util.Log
|
||||
import androidx.core.content.ContextCompat
|
||||
import com.audiobookshelf.app.MainActivity
|
||||
import com.audiobookshelf.app.data.PlaybackSession
|
||||
import com.audiobookshelf.app.device.DeviceManager
|
||||
|
@ -75,11 +77,19 @@ class AbsAudioPlayer : Plugin() {
|
|||
|
||||
@PluginMethod
|
||||
fun prepareLibraryItem(call: PluginCall) {
|
||||
// Need to make sure the player service has been started
|
||||
if (!PlayerNotificationService.isStarted) {
|
||||
Log.w(tag, "prepareLibraryItem: PlayerService not started - Starting foreground service --")
|
||||
Intent(mainActivity, PlayerNotificationService::class.java).also { intent ->
|
||||
ContextCompat.startForegroundService(mainActivity, intent)
|
||||
}
|
||||
}
|
||||
|
||||
var libraryItemId = call.getString("libraryItemId", "").toString()
|
||||
var playWhenReady = call.getBoolean("playWhenReady") == true
|
||||
|
||||
if (libraryItemId.startsWith("local")) { // Play local media item
|
||||
DeviceManager.dbManager.getLocalMediaItem(libraryItemId)?.let {
|
||||
DeviceManager.dbManager.getLocalLibraryItem(libraryItemId)?.let {
|
||||
Handler(Looper.getMainLooper()).post() {
|
||||
Log.d(tag, "Preparing Local Media item ${jacksonObjectMapper().writeValueAsString(it)}")
|
||||
var playbackSession = it.getPlaybackSession()
|
||||
|
|
|
@ -48,23 +48,22 @@ class AbsDatabase : Plugin() {
|
|||
}
|
||||
|
||||
@PluginMethod
|
||||
fun getLocalMediaItemsInFolder(call:PluginCall) {
|
||||
var folderId = call.getString("folderId", "").toString()
|
||||
fun getLocalLibraryItem(call:PluginCall) {
|
||||
var id = call.getString("id", "").toString()
|
||||
GlobalScope.launch(Dispatchers.IO) {
|
||||
var localMediaItems = DeviceManager.dbManager.getLocalMediaItemsInFolder(folderId)
|
||||
var mediaItemsArray = jacksonObjectMapper().writeValueAsString(localMediaItems)
|
||||
var jsobj = JSObject()
|
||||
jsobj.put("localMediaItems", mediaItemsArray)
|
||||
call.resolve(jsobj)
|
||||
var localLibraryItem = DeviceManager.dbManager.getLocalLibraryItem(id)
|
||||
if (localLibraryItem == null) {
|
||||
call.resolve()
|
||||
} else {
|
||||
call.resolve(JSObject(jacksonObjectMapper().writeValueAsString(localLibraryItem)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
fun getLocalLibraryItems(call:PluginCall) {
|
||||
GlobalScope.launch(Dispatchers.IO) {
|
||||
var localLibraryItems = DeviceManager.dbManager.getLocalMediaItems().map {
|
||||
it.getLocalLibraryItem()
|
||||
}
|
||||
var localLibraryItems = DeviceManager.dbManager.getLocalLibraryItems()
|
||||
var jsobj = JSObject()
|
||||
jsobj.put("localLibraryItems", jacksonObjectMapper().writeValueAsString(localLibraryItems))
|
||||
call.resolve(jsobj)
|
||||
|
@ -72,16 +71,14 @@ class AbsDatabase : Plugin() {
|
|||
}
|
||||
|
||||
@PluginMethod
|
||||
fun getLocalLibraryItem(call:PluginCall) {
|
||||
var id = call.getString("id", "").toString()
|
||||
fun getLocalLibraryItemsInFolder(call:PluginCall) {
|
||||
var folderId = call.getString("folderId", "").toString()
|
||||
GlobalScope.launch(Dispatchers.IO) {
|
||||
var mediaItem = DeviceManager.dbManager.getLocalMediaItem(id)
|
||||
var localLibraryItem = mediaItem?.getLocalLibraryItem()
|
||||
if (localLibraryItem == null) {
|
||||
call.resolve()
|
||||
} else {
|
||||
call.resolve(JSObject(jacksonObjectMapper().writeValueAsString(localLibraryItem)))
|
||||
}
|
||||
var localMediaItems = DeviceManager.dbManager.getLocalLibraryItemsInFolder(folderId)
|
||||
var mediaItemsArray = jacksonObjectMapper().writeValueAsString(localMediaItems)
|
||||
var jsobj = JSObject()
|
||||
jsobj.put("localLibraryItems", mediaItemsArray)
|
||||
call.resolve(jsobj)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -118,11 +118,11 @@ class AbsDownloader : Plugin() {
|
|||
fun getAbMetadataText(libraryItem:LibraryItem):String {
|
||||
var bookMedia = libraryItem.media as com.audiobookshelf.app.data.Book
|
||||
var fileString = ";ABMETADATA1\n"
|
||||
fileString += "#libraryItemId=${libraryItem.id}\n"
|
||||
fileString += "title=${bookMedia.metadata.title}\n"
|
||||
fileString += "author=${bookMedia.metadata.authorName}\n"
|
||||
fileString += "narrator=${bookMedia.metadata.narratorName}\n"
|
||||
fileString += "series=${bookMedia.metadata.seriesName}\n"
|
||||
// fileString += "#libraryItemId=${libraryItem.id}\n"
|
||||
// fileString += "title=${bookMedia.metadata.title}\n"
|
||||
// fileString += "author=${bookMedia.metadata.authorName}\n"
|
||||
// fileString += "narrator=${bookMedia.metadata.narratorName}\n"
|
||||
// fileString += "series=${bookMedia.metadata.seriesName}\n"
|
||||
return fileString
|
||||
}
|
||||
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
<template>
|
||||
<div v-if="playbackSession" class="fixed top-0 left-0 layout-wrapper right-0 z-50 pointer-events-none" :class="showFullscreen ? 'fullscreen' : ''">
|
||||
<div v-if="playbackSession" id="streamContainer" class="fixed top-0 left-0 layout-wrapper right-0 z-50 pointer-events-none" :class="showFullscreen ? 'fullscreen' : ''">
|
||||
<div v-if="showFullscreen" class="w-full h-full z-10 bg-bg absolute top-0 left-0 pointer-events-auto">
|
||||
<div class="top-2 left-4 absolute cursor-pointer">
|
||||
<span class="material-icons text-5xl" @click="collapseFullscreen">expand_more</span>
|
||||
|
@ -31,7 +31,7 @@
|
|||
|
||||
<div class="cover-wrapper absolute z-30 pointer-events-auto" :class="bookCoverAspectRatio === 1 ? 'square-cover' : ''" @click="clickContainer">
|
||||
<div class="cover-container bookCoverWrapper bg-black bg-opacity-75 w-full h-full">
|
||||
<covers-book-cover v-if="libraryItem || localMediaItemCoverSrc" :library-item="libraryItem" :download-cover="localMediaItemCoverSrc" :width="bookCoverWidth" :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
||||
<covers-book-cover v-if="libraryItem || localLibraryItemCoverSrc" :library-item="libraryItem" :download-cover="localLibraryItemCoverSrc" :width="bookCoverWidth" :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
@ -175,12 +175,12 @@ export default {
|
|||
libraryItem() {
|
||||
return this.playbackSession ? this.playbackSession.libraryItem || null : null
|
||||
},
|
||||
localMediaItem() {
|
||||
return this.playbackSession ? this.playbackSession.localMediaItem || null : null
|
||||
localLibraryItem() {
|
||||
return this.playbackSession ? this.playbackSession.localLibraryItem || null : null
|
||||
},
|
||||
localMediaItemCoverSrc() {
|
||||
var localMediaItemCover = this.localMediaItem ? this.localMediaItem.coverContentUrl : null
|
||||
if (localMediaItemCover) return Capacitor.convertFileSrc(localMediaItemCover)
|
||||
localLibraryItemCoverSrc() {
|
||||
var localItemCover = this.localLibraryItem ? this.localLibraryItem.coverContentUrl : null
|
||||
if (localItemCover) return Capacitor.convertFileSrc(localItemCover)
|
||||
return null
|
||||
},
|
||||
playMethod() {
|
||||
|
|
|
@ -1,8 +1,6 @@
|
|||
<template>
|
||||
<div>
|
||||
<div id="streamContainer">
|
||||
<app-audio-player ref="audioPlayer" :playing.sync="isPlaying" :bookmarks="bookmarks" :sleep-timer-running="isSleepTimerRunning" :sleep-time-remaining="sleepTimeRemaining" @selectPlaybackSpeed="showPlaybackSpeedModal = true" @updateTime="(t) => (currentTime = t)" @showSleepTimer="showSleepTimer" @showBookmarks="showBookmarks" />
|
||||
</div>
|
||||
|
||||
<modals-playback-speed-modal v-model="showPlaybackSpeedModal" :playback-rate.sync="playbackSpeed" @update:playbackRate="updatePlaybackSpeed" @change="changePlaybackSpeed" />
|
||||
<modals-sleep-timer-modal v-model="showSleepTimerModal" :current-time="sleepTimeRemaining" :sleep-timer-running="isSleepTimerRunning" :current-end-of-chapter-time="currentEndOfChapterTime" @change="selectSleepTimeout" @cancel="cancelSleepTimer" @increase="increaseSleepTimer" @decrease="decreaseSleepTimer" />
|
||||
|
|
|
@ -11,7 +11,7 @@
|
|||
</button>
|
||||
|
||||
<transition name="menu">
|
||||
<ul v-show="showMenu" class="absolute z-10 -mt-px w-full bg-primary border border-black-200 shadow-lg max-h-56 rounded-b-md py-1 ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none sm:text-sm" tabindex="-1" role="listbox">
|
||||
<ul v-show="showMenu" class="absolute z-10 -mt-px w-full bg-primary border border-gray-600 shadow-lg max-h-56 rounded-b-md py-1 ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none sm:text-sm" tabindex="-1" role="listbox">
|
||||
<template v-for="item in items">
|
||||
<li :key="item.value" class="text-gray-100 select-none relative py-2 cursor-pointer hover:bg-black-400" id="listbox-option-0" role="option" @click="clickedOption(item.value)">
|
||||
<div class="flex items-center">
|
||||
|
|
|
@ -97,7 +97,7 @@ export default {
|
|||
var localCategories = await this.getLocalMediaItemCategories()
|
||||
this.shelves = this.shelves.concat(localCategories)
|
||||
|
||||
if (this.user || !this.currentLibraryId) {
|
||||
if (this.user && this.currentLibraryId) {
|
||||
var categories = await this.$axios
|
||||
.$get(`/api/libraries/${this.currentLibraryId}/personalized?minified=1`)
|
||||
.then((data) => {
|
||||
|
|
|
@ -3,7 +3,7 @@
|
|||
<div class="flex">
|
||||
<div class="w-32">
|
||||
<div class="relative">
|
||||
<covers-book-cover :library-item="libraryItem" :download-cover="downloadedCover" :width="128" :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
||||
<covers-book-cover :library-item="libraryItem" :width="128" :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
||||
<div class="absolute bottom-0 left-0 h-1.5 bg-yellow-400 shadow-sm z-10" :style="{ width: 128 * progressPercent + 'px' }"></div>
|
||||
</div>
|
||||
<div class="flex my-4">
|
||||
|
|
|
@ -3,23 +3,24 @@
|
|||
<div class="flex items-center mb-4">
|
||||
<div class="flex-grow" />
|
||||
<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-btn v-if="!removingFolder && localLibraryItems.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>
|
||||
<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 Library Items ({{ localLibraryItems.length }})</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">
|
||||
<template v-for="mediaItem in localMediaItems">
|
||||
<template v-for="mediaItem in localLibraryItems">
|
||||
<div :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>
|
||||
<div class="flex-grow px-2">
|
||||
<p>{{ mediaItem.name }}</p>
|
||||
<p>{{ mediaItem.audioTracks.length }} Tracks</p>
|
||||
<p>{{ mediaItem.media.metadata.title }}</p>
|
||||
<p v-if="mediaItem.type == 'book'">{{ mediaItem.media.tracks.length }} Tracks</p>
|
||||
<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)">
|
||||
|
@ -46,7 +47,7 @@ export default {
|
|||
},
|
||||
data() {
|
||||
return {
|
||||
localMediaItems: [],
|
||||
localLibraryItems: [],
|
||||
folder: null,
|
||||
isScanning: false,
|
||||
removingFolder: false
|
||||
|
@ -66,8 +67,8 @@ export default {
|
|||
},
|
||||
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)`
|
||||
if (this.localLibraryItems.length) {
|
||||
deleteMessage = `Are you sure you want to remove this folder and ${this.localLibraryItems.length} items? (does not delete anything in your file system)`
|
||||
}
|
||||
const { value } = await Dialog.confirm({
|
||||
title: 'Confirm',
|
||||
|
@ -87,7 +88,7 @@ export default {
|
|||
this.isScanning = true
|
||||
var response = await AbsFileSystem.scanFolder({ folderId: this.folderId, forceAudioProbe })
|
||||
|
||||
if (response && response.localMediaItems) {
|
||||
if (response && response.localLibraryItems) {
|
||||
var itemsAdded = response.itemsAdded
|
||||
var itemsUpdated = response.itemsUpdated
|
||||
var itemsRemoved = response.itemsRemoved
|
||||
|
@ -100,14 +101,14 @@ export default {
|
|||
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) => {
|
||||
if (response.localLibraryItems.length) {
|
||||
this.localLibraryItems = response.localLibraryItems.map((mi) => {
|
||||
if (mi.coverContentUrl) {
|
||||
mi.coverPathSrc = Capacitor.convertFileSrc(mi.coverContentUrl)
|
||||
}
|
||||
return mi
|
||||
})
|
||||
console.log('Set Local Media Items', this.localMediaItems.length)
|
||||
console.log('Set Local Media Items', this.localLibraryItems.length)
|
||||
}
|
||||
} else {
|
||||
console.log('No Local media items found')
|
||||
|
@ -118,9 +119,9 @@ export default {
|
|||
var folder = await this.$db.getLocalFolder(this.folderId)
|
||||
this.folder = folder
|
||||
|
||||
var items = (await this.$db.getLocalMediaItemsInFolder(this.folderId)) || []
|
||||
var items = (await this.$db.getLocalLibraryItemsInFolder(this.folderId)) || []
|
||||
console.log('Init folder', this.folderId, items)
|
||||
this.localMediaItems = items.map((lmi) => {
|
||||
this.localLibraryItems = items.map((lmi) => {
|
||||
return {
|
||||
...lmi,
|
||||
coverPathSrc: lmi.coverContentUrl ? Capacitor.convertFileSrc(lmi.coverContentUrl) : null
|
||||
|
|
|
@ -59,6 +59,7 @@ export default {
|
|||
return this.$toast.error('Must select a media type')
|
||||
}
|
||||
var folderObj = await AbsFileSystem.selectFolder({ mediaType: this.newFolderMediaType })
|
||||
if (!folderObj) return
|
||||
if (folderObj.error) {
|
||||
return this.$toast.error(`Error: ${folderObj.error || 'Unknown Error'}`)
|
||||
}
|
||||
|
|
|
@ -4,6 +4,8 @@ class AbsFileSystemWeb extends WebPlugin {
|
|||
constructor() {
|
||||
super()
|
||||
}
|
||||
|
||||
async selectFolder() { }
|
||||
}
|
||||
|
||||
const AbsFileSystem = registerPlugin('AbsFileSystem', {
|
||||
|
|
|
@ -73,14 +73,14 @@ class DbService {
|
|||
})
|
||||
}
|
||||
|
||||
getLocalMediaItemsInFolder(folderId) {
|
||||
getLocalLibraryItemsInFolder(folderId) {
|
||||
if (isWeb) return []
|
||||
return AbsDatabase.getLocalMediaItemsInFolder({ folderId }).then((data) => {
|
||||
console.log('Loaded local media items in folder', JSON.stringify(data))
|
||||
if (data.localMediaItems && typeof data.localMediaItems == 'string') {
|
||||
return JSON.parse(data.localMediaItems)
|
||||
return AbsDatabase.getLocalLibraryItemsInFolder({ folderId }).then((data) => {
|
||||
console.log('Loaded local library items in folder', JSON.stringify(data))
|
||||
if (data.localLibraryItems && typeof data.localLibraryItems == 'string') {
|
||||
return JSON.parse(data.localLibraryItems)
|
||||
}
|
||||
return data.localMediaItems
|
||||
return data.localLibraryItems
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
@ -38,7 +38,7 @@ export const getters = {
|
|||
return state.serverSettings[key]
|
||||
},
|
||||
getBookCoverAspectRatio: state => {
|
||||
if (!state.serverSettings || !state.serverSettings.coverAspectRatio) return 1.6
|
||||
if (!state.serverSettings || !state.serverSettings.coverAspectRatio) return 1
|
||||
return state.serverSettings.coverAspectRatio === 0 ? 1.6 : 1
|
||||
},
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue