merge develop

This commit is contained in:
James Wells 2021-07-04 15:41:56 -04:00
commit 04de4544ee
No known key found for this signature in database
GPG Key ID: DB1528F6EED16127
62 changed files with 631 additions and 818 deletions

View File

@ -1,12 +0,0 @@
apply from: bootstrap.kotlinModule
dependencies {
api project(':core:domain')
api other.twitterSerial
testImplementation testing.kotlinJunit
testImplementation testing.mockito
testImplementation testing.mockitoInline
testImplementation testing.mockitoKotlin
testImplementation testing.kluent
}

View File

@ -1,14 +0,0 @@
package org.moire.ultrasonic.cache
import java.io.File
/**
* Provides access to generic directories:
* - for temporary caches
* - for permanent data storage
*/
interface Directories {
fun getInternalCacheDir(): File
fun getInternalDataDir(): File
fun getExternalCacheDir(): File?
}

View File

@ -1,75 +0,0 @@
package org.moire.ultrasonic.cache
import com.twitter.serial.serializer.SerializationContext
import com.twitter.serial.serializer.Serializer
import com.twitter.serial.stream.Serial
import com.twitter.serial.stream.bytebuffer.ByteBufferSerial
import java.io.File
typealias DomainEntitySerializer<T> = Serializer<T>
internal const val STORAGE_DIR_NAME = "persistent_storage"
/**
* Provides access to permanent file based storage.
*
* [serverId] is currently active server. Should be unique per server so stored data will not
* interfere with other server data.
*
* Look at [org.moire.ultrasonic.cache.serializers] package for available [DomainEntitySerializer]s.
*/
class PermanentFileStorage(
private val directories: Directories,
private val serverId: String,
private val debug: Boolean = false
) {
private val serializationContext = object : SerializationContext {
override fun isDebug(): Boolean = debug
override fun isRelease(): Boolean = !debug
}
private val serializer: Serial = ByteBufferSerial(serializationContext)
/**
* Stores given [objectToStore] using [name] as a key and [objectSerializer] as serializer.
*/
fun <T> store(
name: String,
objectToStore: T,
objectSerializer: DomainEntitySerializer<T>
) {
val storeFile = getFile(name)
if (!storeFile.exists()) storeFile.createNewFile()
storeFile.writeBytes(serializer.toByteArray(objectToStore, objectSerializer))
}
/**
* Loads object with [name] key using [objectDeserializer] deserializer.
*/
fun <T> load(
name: String,
objectDeserializer: DomainEntitySerializer<T>
): T? {
val storeFile = getFile(name)
if (!storeFile.exists()) return null
return serializer.fromByteArray(storeFile.readBytes(), objectDeserializer)
}
/**
* Clear all files in storage.
*/
fun clearAll() {
val storageDir = getStorageDir()
storageDir.listFiles().forEach { it.deleteRecursively() }
}
private fun getFile(name: String) = File(getStorageDir(), "$name.ser")
private fun getStorageDir(): File {
val mainDir = File(directories.getInternalDataDir(), STORAGE_DIR_NAME)
val serverDir = File(mainDir, serverId)
if (!serverDir.exists()) serverDir.mkdirs()
return serverDir
}
}

View File

@ -1,65 +0,0 @@
@file:JvmMultifileClass
@file:JvmName("DomainSerializers")
package org.moire.ultrasonic.cache.serializers
import com.twitter.serial.serializer.CollectionSerializers
import com.twitter.serial.serializer.ObjectSerializer
import com.twitter.serial.serializer.SerializationContext
import com.twitter.serial.stream.SerializerDefs
import com.twitter.serial.stream.SerializerInput
import com.twitter.serial.stream.SerializerOutput
import org.moire.ultrasonic.cache.DomainEntitySerializer
import org.moire.ultrasonic.domain.Artist
private const val SERIALIZER_VERSION = 1
private val artistSerializer get() = object : ObjectSerializer<Artist>(SERIALIZER_VERSION) {
override fun serializeObject(
context: SerializationContext,
output: SerializerOutput<out SerializerOutput<*>>,
item: Artist
) {
output.writeString(item.id)
.writeString(item.name)
.writeString(item.index)
.writeString(item.coverArt)
.apply {
val albumCount = item.albumCount
if (albumCount != null) writeLong(albumCount) else writeNull()
}
.writeInt(item.closeness)
}
override fun deserializeObject(
context: SerializationContext,
input: SerializerInput,
versionNumber: Int
): Artist? {
if (versionNumber != SERIALIZER_VERSION) return null
val id = input.readString()
val name = input.readString()
val index = input.readString()
val coverArt = input.readString()
val albumCount = if (input.peekType() == SerializerDefs.TYPE_NULL) {
input.readNull()
null
} else {
input.readLong()
}
val closeness = input.readInt()
return Artist(id, name, index, coverArt, albumCount, closeness)
}
}
/**
* Serializer/deserializer for [Artist] domain entity.
*/
fun getArtistsSerializer(): DomainEntitySerializer<Artist> = artistSerializer
private val artistListSerializer = CollectionSerializers.getListSerializer(artistSerializer)
/**
* Serializer/deserializer for list of [Artist] domain entities.
*/
fun getArtistListSerializer(): DomainEntitySerializer<List<Artist>> = artistListSerializer

View File

@ -1,51 +0,0 @@
@file:JvmMultifileClass
@file:JvmName("DomainSerializers")
package org.moire.ultrasonic.cache.serializers
import com.twitter.serial.serializer.ObjectSerializer
import com.twitter.serial.serializer.SerializationContext
import com.twitter.serial.stream.SerializerInput
import com.twitter.serial.stream.SerializerOutput
import org.moire.ultrasonic.cache.DomainEntitySerializer
import org.moire.ultrasonic.domain.Artist
import org.moire.ultrasonic.domain.Indexes
private const val SERIALIZATION_VERSION = 1
private val indexesSerializer get() = object : ObjectSerializer<Indexes>(SERIALIZATION_VERSION) {
override fun serializeObject(
context: SerializationContext,
output: SerializerOutput<out SerializerOutput<*>>,
item: Indexes
) {
val artistListSerializer = getArtistListSerializer()
output.writeLong(item.lastModified)
.writeString(item.ignoredArticles)
.writeObject<MutableList<Artist>>(context, item.shortcuts, artistListSerializer)
.writeObject<MutableList<Artist>>(context, item.artists, artistListSerializer)
}
@Suppress("ReturnCount")
override fun deserializeObject(
context: SerializationContext,
input: SerializerInput,
versionNumber: Int
): Indexes? {
if (versionNumber != SERIALIZATION_VERSION) return null
val artistListDeserializer = getArtistListSerializer()
val lastModified = input.readLong()
val ignoredArticles = input.readString() ?: return null
val shortcutsList = input.readObject(context, artistListDeserializer) ?: return null
val artistsList = input.readObject(context, artistListDeserializer) ?: return null
return Indexes(
lastModified, ignoredArticles, shortcutsList.toMutableList(),
artistsList.toMutableList()
)
}
}
/**
* Get serializer/deserializer for [Indexes] entity.
*/
fun getIndexesSerializer(): DomainEntitySerializer<Indexes> = indexesSerializer

View File

@ -1,51 +0,0 @@
@file:JvmMultifileClass
@file:JvmName("DomainSerializers")
package org.moire.ultrasonic.cache.serializers
import com.twitter.serial.serializer.CollectionSerializers
import com.twitter.serial.serializer.ObjectSerializer
import com.twitter.serial.serializer.SerializationContext
import com.twitter.serial.stream.SerializerInput
import com.twitter.serial.stream.SerializerOutput
import org.moire.ultrasonic.cache.DomainEntitySerializer
import org.moire.ultrasonic.domain.MusicFolder
private const val SERIALIZATION_VERSION = 1
private val musicFolderSerializer = object : ObjectSerializer<MusicFolder>(SERIALIZATION_VERSION) {
override fun serializeObject(
context: SerializationContext,
output: SerializerOutput<out SerializerOutput<*>>,
item: MusicFolder
) {
output.writeString(item.id).writeString(item.name)
}
@Suppress("ReturnCount")
override fun deserializeObject(
context: SerializationContext,
input: SerializerInput,
versionNumber: Int
): MusicFolder? {
if (versionNumber != SERIALIZATION_VERSION) return null
val id = input.readString() ?: return null
val name = input.readString() ?: return null
return MusicFolder(id, name)
}
}
/**
* Serializer/deserializer for [MusicFolder] domain entity.
*/
fun getMusicFolderSerializer(): DomainEntitySerializer<MusicFolder> = musicFolderSerializer
private val musicFolderListSerializer =
CollectionSerializers.getListSerializer(musicFolderSerializer)
/**
* Serializer/deserializer for [List] of [MusicFolder] items.
*/
fun getMusicFolderListSerializer(): DomainEntitySerializer<List<MusicFolder>> =
musicFolderListSerializer

View File

@ -1,42 +0,0 @@
package org.moire.ultrasonic.cache
import com.twitter.serial.util.SerializationUtils
import java.io.File
import org.amshove.kluent.`it returns`
import org.junit.Before
import org.junit.Rule
import org.junit.rules.TemporaryFolder
import org.mockito.kotlin.mock
internal const val INTERNAL_DATA_FOLDER = "data"
internal const val INTERNAL_CACHE_FOLDER = "cache"
internal const val EXTERNAL_CACHE_FOLDER = "external_cache"
/**
* Base test class that inits the storage
*/
abstract class BaseStorageTest {
@get:Rule val tempFileRule = TemporaryFolder()
protected lateinit var mockDirectories: Directories
protected lateinit var storage: PermanentFileStorage
open val serverId: String = ""
@Before
fun setUp() {
mockDirectories = mock<Directories> {
on { getInternalDataDir() } `it returns` tempFileRule.newFolder(INTERNAL_DATA_FOLDER)
on { getInternalCacheDir() } `it returns` tempFileRule.newFolder(INTERNAL_CACHE_FOLDER)
on { getExternalCacheDir() } `it returns` tempFileRule.newFolder(EXTERNAL_CACHE_FOLDER)
}
storage = PermanentFileStorage(mockDirectories, serverId, true)
}
protected val storageDir get() = File(mockDirectories.getInternalDataDir(), STORAGE_DIR_NAME)
protected fun validateSerializedData(index: Int = 0) {
val serializedFileBytes = storageDir.listFiles()[index].readBytes()
SerializationUtils.validateSerializedData(serializedFileBytes)
}
}

View File

@ -1,80 +0,0 @@
package org.moire.ultrasonic.cache
import java.io.File
import org.amshove.kluent.`should be equal to`
import org.amshove.kluent.`should contain`
import org.junit.Test
import org.moire.ultrasonic.cache.serializers.getMusicFolderSerializer
import org.moire.ultrasonic.domain.MusicFolder
/**
* Integration test for [PermanentFileStorage].
*/
class PermanentFileStorageTest : BaseStorageTest() {
override val serverId: String
get() = "some-server-id"
@Test
fun `Should create storage dir if it is not exist`() {
val item = MusicFolder("1", "2")
storage.store("test", item, getMusicFolderSerializer())
storageDir.exists() `should be equal to` true
getServerStorageDir().exists() `should be equal to` true
}
@Test
fun `Should serialize to file`() {
val item = MusicFolder("1", "23")
val name = "some-name"
storage.store(name, item, getMusicFolderSerializer())
val storageFiles = getServerStorageDir().listFiles()
storageFiles.size `should be equal to` 1
storageFiles[0].name `should contain` name
}
@Test
fun `Should deserialize stored object`() {
val item = MusicFolder("some", "nice")
val name = "some-name"
storage.store(name, item, getMusicFolderSerializer())
val loadedItem = storage.load(name, getMusicFolderSerializer())
loadedItem `should be equal to` item
}
@Test
fun `Should overwrite existing stored object`() {
val name = "some-nice-name"
val item1 = MusicFolder("1", "1")
val item2 = MusicFolder("2", "2")
storage.store(name, item1, getMusicFolderSerializer())
storage.store(name, item2, getMusicFolderSerializer())
val loadedItem = storage.load(name, getMusicFolderSerializer())
loadedItem `should be equal to` item2
}
@Test
fun `Should clear all files when clearAll is called`() {
storage.store("name1", MusicFolder("1", "1"), getMusicFolderSerializer())
storage.store("name2", MusicFolder("2", "2"), getMusicFolderSerializer())
storage.clearAll()
getServerStorageDir().listFiles().size `should be equal to` 0
}
@Test
fun `Should return null if serialized file not available`() {
val loadedItem = storage.load("some-name", getMusicFolderSerializer())
loadedItem `should be equal to` null
}
private fun getServerStorageDir() = File(storageDir, serverId)
}

View File

@ -1,57 +0,0 @@
package org.moire.ultrasonic.cache.serializers
import org.amshove.kluent.`should be equal to`
import org.junit.Test
import org.moire.ultrasonic.cache.BaseStorageTest
import org.moire.ultrasonic.domain.Artist
/**
* [Artist] serializers test.
*/
class ArtistSerializerTest : BaseStorageTest() {
@Test
fun `Should correctly serialize Artist object`() {
val item = Artist("id", "name", "index", "coverArt", 1, 0)
storage.store("some-name", item, getArtistsSerializer())
validateSerializedData()
}
@Test
fun `Should correctly deserialize Artist object`() {
val itemName = "some-name"
val item = Artist("id", "name", "index", "coverArt", null, 0)
storage.store(itemName, item, getArtistsSerializer())
val loadedItem = storage.load(itemName, getArtistsSerializer())
loadedItem `should be equal to` item
}
@Test
fun `Should correctly serialize list of Artists`() {
val itemsList = listOf(
Artist(id = "1"),
Artist(id = "2", name = "some")
)
storage.store("some-name", itemsList, getArtistListSerializer())
validateSerializedData()
}
@Test
fun `Should correctly deserialize list of Artists`() {
val name = "some-name"
val itemsList = listOf(
Artist(id = "1"),
Artist(id = "2", name = "some")
)
storage.store(name, itemsList, getArtistListSerializer())
val loadedItems = storage.load(name, getArtistListSerializer())
loadedItems `should be equal to` itemsList
}
}

View File

@ -1,38 +0,0 @@
package org.moire.ultrasonic.cache.serializers
import org.amshove.kluent.`should be equal to`
import org.junit.Test
import org.moire.ultrasonic.cache.BaseStorageTest
import org.moire.ultrasonic.domain.Artist
import org.moire.ultrasonic.domain.Indexes
/**
* Test [Indexes] domain entity serializer.
*/
class IndexesSerializerTest : BaseStorageTest() {
@Test
fun `Should correctly serialize Indexes object`() {
val item = Indexes(
220L, "", mutableListOf(Artist("12")),
mutableListOf(Artist("233", "some"))
)
storage.store("some-name", item, getIndexesSerializer())
validateSerializedData()
}
@Test
fun `Should correctly deserialize Indexes object`() {
val name = "some-name"
val item = Indexes(
220L, "", mutableListOf(Artist("12")),
mutableListOf(Artist("233", "some"))
)
storage.store(name, item, getIndexesSerializer())
val loadedItem = storage.load(name, getIndexesSerializer())
loadedItem `should be equal to` item
}
}

View File

@ -1,57 +0,0 @@
package org.moire.ultrasonic.cache.serializers
import org.amshove.kluent.`should be equal to`
import org.junit.Test
import org.moire.ultrasonic.cache.BaseStorageTest
import org.moire.ultrasonic.domain.MusicFolder
/**
* [MusicFolder] serializers test.
*/
class MusicFolderSerializerTest : BaseStorageTest() {
@Test
fun `Should correctly serialize MusicFolder object`() {
val item = MusicFolder("Music", "Folder")
storage.store("some-name", item, getMusicFolderSerializer())
validateSerializedData()
}
@Test
fun `Should correctly deserialize MusicFolder object`() {
val name = "name"
val item = MusicFolder("some", "none")
storage.store(name, item, getMusicFolderSerializer())
val loadedItem = storage.load(name, getMusicFolderSerializer())
loadedItem `should be equal to` item
}
@Test
fun `Should correctly serialize list of MusicFolders objects`() {
val itemsList = listOf(
MusicFolder("1", "1"),
MusicFolder("2", "2")
)
storage.store("some-name", itemsList, getMusicFolderListSerializer())
validateSerializedData()
}
@Test
fun `Should correctly deserialize list of MusicFolder objects`() {
val name = "some-name"
val itemsList = listOf(
MusicFolder("1", "1"),
MusicFolder("2", "2")
)
storage.store(name, itemsList, getMusicFolderListSerializer())
val loadedItem = storage.load(name, getMusicFolderListSerializer())
loadedItem `should be equal to` itemsList
}
}

View File

@ -1,7 +1,14 @@
apply from: bootstrap.kotlinModule apply from: bootstrap.androidModule
apply plugin: 'kotlin-kapt'
ext { ext {
jacocoExclude = [ jacocoExclude = [
'**/domain/**' '**/domain/**'
] ]
} }
dependencies {
implementation androidSupport.roomRuntime
implementation androidSupport.roomKtx
kapt androidSupport.room
}

View File

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="org.moire.ultrasonic.subsonic.domain">
</manifest>

View File

@ -1,18 +1,17 @@
package org.moire.ultrasonic.domain package org.moire.ultrasonic.domain
import java.io.Serializable import androidx.room.Entity
import androidx.room.PrimaryKey
@Entity(tableName = "artists")
data class Artist( data class Artist(
override var id: String? = null, @PrimaryKey override var id: String,
override var name: String? = null, override var name: String? = null,
var index: String? = null, override var index: String? = null,
var coverArt: String? = null, override var coverArt: String? = null,
var albumCount: Long? = null, override var albumCount: Long? = null,
var closeness: Int = 0 override var closeness: Int = 0
) : Serializable, GenericEntry(), Comparable<Artist> { ) : ArtistOrIndex(id), Comparable<Artist> {
companion object {
private const val serialVersionUID = -5790532593784846982L
}
override fun compareTo(other: Artist): Int { override fun compareTo(other: Artist): Int {
when { when {

View File

@ -0,0 +1,18 @@
package org.moire.ultrasonic.domain
import androidx.room.Ignore
open class ArtistOrIndex(
@Ignore
override var id: String,
@Ignore
override var name: String? = null,
@Ignore
open var index: String? = null,
@Ignore
open var coverArt: String? = null,
@Ignore
open var albumCount: Long? = null,
@Ignore
open var closeness: Int = 0
) : GenericEntry()

View File

@ -1,8 +1,12 @@
package org.moire.ultrasonic.domain package org.moire.ultrasonic.domain
abstract class GenericEntry { import androidx.room.Ignore
// TODO: Should be non-null!
abstract val id: String? open class GenericEntry {
// TODO Should be non-null!
@Ignore
open val id: String? = null
@Ignore
open val name: String? = null open val name: String? = null
// These are just a formality and will never be called, // These are just a formality and will never be called,

View File

@ -1,11 +1,14 @@
package org.moire.ultrasonic.domain package org.moire.ultrasonic.domain
import androidx.room.Entity
import androidx.room.PrimaryKey
import java.io.Serializable import java.io.Serializable
@Entity
data class Genre( data class Genre(
val name: String, @PrimaryKey val index: String,
val index: String override val name: String
) : Serializable { ) : Serializable, GenericEntry() {
companion object { companion object {
private const val serialVersionUID = -3943025175219134028L private const val serialVersionUID = -3943025175219134028L
} }

View File

@ -0,0 +1,15 @@
package org.moire.ultrasonic.domain
import androidx.room.Entity
import androidx.room.PrimaryKey
@Entity(tableName = "indexes")
data class Index(
@PrimaryKey override var id: String,
override var name: String? = null,
override var index: String? = null,
override var coverArt: String? = null,
override var albumCount: Long? = null,
override var closeness: Int = 0,
var musicFolderId: String? = null
) : ArtistOrIndex(id)

View File

@ -1,14 +0,0 @@
package org.moire.ultrasonic.domain
import java.io.Serializable
data class Indexes(
val lastModified: Long,
val ignoredArticles: String,
val shortcuts: MutableList<Artist> = mutableListOf(),
val artists: MutableList<Artist> = mutableListOf()
) : Serializable {
companion object {
private const val serialVersionUID = 8156117238598414701L
}
}

View File

@ -1,5 +1,7 @@
package org.moire.ultrasonic.domain package org.moire.ultrasonic.domain
import androidx.room.Entity
import androidx.room.PrimaryKey
import java.io.Serializable import java.io.Serializable
import java.util.Date import java.util.Date
@ -35,8 +37,9 @@ class MusicDirectory {
return children.filter { it.isDirectory && includeDirs || !it.isDirectory && includeFiles } return children.filter { it.isDirectory && includeDirs || !it.isDirectory && includeFiles }
} }
@Entity
data class Entry( data class Entry(
override var id: String, @PrimaryKey override var id: String,
var parent: String? = null, var parent: String? = null,
var isDirectory: Boolean = false, var isDirectory: Boolean = false,
var title: String? = null, var title: String? = null,

View File

@ -1,9 +1,13 @@
package org.moire.ultrasonic.domain package org.moire.ultrasonic.domain
import androidx.room.Entity
import androidx.room.PrimaryKey
/** /**
* Represents a top level directory in which music or other media is stored. * Represents a top level directory in which music or other media is stored.
*/ */
@Entity(tableName = "music_folders")
data class MusicFolder( data class MusicFolder(
override val id: String, @PrimaryKey override val id: String,
override val name: String override val name: String
) : GenericEntry() ) : GenericEntry()

View File

@ -1,5 +1,6 @@
package org.moire.ultrasonic.api.subsonic.models package org.moire.ultrasonic.api.subsonic.models
import java.util.Locale
import org.amshove.kluent.`should be equal to` import org.amshove.kluent.`should be equal to`
import org.amshove.kluent.`should throw` import org.amshove.kluent.`should throw`
import org.junit.Test import org.junit.Test
@ -10,7 +11,7 @@ import org.junit.Test
class AlbumListTypeTest { class AlbumListTypeTest {
@Test @Test
fun `Should create type from string ignoring case`() { fun `Should create type from string ignoring case`() {
val type = AlbumListType.SORTED_BY_NAME.typeName.toLowerCase() val type = AlbumListType.SORTED_BY_NAME.typeName.lowercase(Locale.ROOT)
val albumListType = AlbumListType.fromName(type) val albumListType = AlbumListType.fromName(type)

View File

@ -28,14 +28,13 @@ ext.versions = [
retrofit : "2.6.4", retrofit : "2.6.4",
jackson : "2.9.5", jackson : "2.9.5",
okhttp : "3.12.13", okhttp : "3.12.13",
twitterSerial : "0.1.6",
koin : "3.0.2", koin : "3.0.2",
picasso : "2.71828", picasso : "2.71828",
sortListView : "1.0.1", sortListView : "1.0.1",
junit4 : "4.13.2", junit4 : "4.13.2",
junit5 : "5.7.1", junit5 : "5.7.1",
mockito : "3.11.0", mockito : "3.11.2",
mockitoKotlin : "3.2.0", mockitoKotlin : "3.2.0",
kluent : "1.64", kluent : "1.64",
apacheCodecs : "1.15", apacheCodecs : "1.15",
@ -82,7 +81,6 @@ ext.other = [
jacksonConverter : "com.squareup.retrofit2:converter-jackson:$versions.retrofit", jacksonConverter : "com.squareup.retrofit2:converter-jackson:$versions.retrofit",
jacksonKotlin : "com.fasterxml.jackson.module:jackson-module-kotlin:$versions.jackson", jacksonKotlin : "com.fasterxml.jackson.module:jackson-module-kotlin:$versions.jackson",
okhttpLogging : "com.squareup.okhttp3:logging-interceptor:$versions.okhttp", okhttpLogging : "com.squareup.okhttp3:logging-interceptor:$versions.okhttp",
twitterSerial : "com.twitter.serial:serial:$versions.twitterSerial",
koinCore : "io.insert-koin:koin-core:$versions.koin", koinCore : "io.insert-koin:koin-core:$versions.koin",
koinAndroid : "io.insert-koin:koin-android:$versions.koin", koinAndroid : "io.insert-koin:koin-android:$versions.koin",
koinViewModel : "io.insert-koin:koin-android-viewmodel:$versions.koin", koinViewModel : "io.insert-koin:koin-android-viewmodel:$versions.koin",

View File

@ -10,7 +10,6 @@
<ID>ComplexMethod:SongView.kt$SongView$fun setSong(song: MusicDirectory.Entry, checkable: Boolean, draggable: Boolean)</ID> <ID>ComplexMethod:SongView.kt$SongView$fun setSong(song: MusicDirectory.Entry, checkable: Boolean, draggable: Boolean)</ID>
<ID>ComplexMethod:TrackCollectionFragment.kt$TrackCollectionFragment$private fun enableButtons()</ID> <ID>ComplexMethod:TrackCollectionFragment.kt$TrackCollectionFragment$private fun enableButtons()</ID>
<ID>ComplexMethod:TrackCollectionFragment.kt$TrackCollectionFragment$private fun updateInterfaceWithEntries(musicDirectory: MusicDirectory)</ID> <ID>ComplexMethod:TrackCollectionFragment.kt$TrackCollectionFragment$private fun updateInterfaceWithEntries(musicDirectory: MusicDirectory)</ID>
<ID>EmptyFunctionBlock:SongView.kt$SongView${}</ID>
<ID>FunctionNaming:ThemeChangedEventDistributor.kt$ThemeChangedEventDistributor$fun RaiseThemeChangedEvent()</ID> <ID>FunctionNaming:ThemeChangedEventDistributor.kt$ThemeChangedEventDistributor$fun RaiseThemeChangedEvent()</ID>
<ID>ImplicitDefaultLocale:DownloadFile.kt$DownloadFile$String.format("DownloadFile (%s)", song)</ID> <ID>ImplicitDefaultLocale:DownloadFile.kt$DownloadFile$String.format("DownloadFile (%s)", song)</ID>
<ID>ImplicitDefaultLocale:DownloadFile.kt$DownloadFile.DownloadTask$String.format("Download of '%s' was cancelled", song)</ID> <ID>ImplicitDefaultLocale:DownloadFile.kt$DownloadFile.DownloadTask$String.format("Download of '%s' was cancelled", song)</ID>

View File

@ -45,6 +45,7 @@ complexity:
thresholdInFiles: 20 thresholdInFiles: 20
thresholdInClasses: 20 thresholdInClasses: 20
thresholdInInterfaces: 20 thresholdInInterfaces: 20
thresholdInObjects: 30
LabeledExpression: LabeledExpression:
active: false active: false

View File

@ -0,0 +1,12 @@
Bug fixes
- #368: Empty id in getCoverArt.
- #528: Saving playlists does not work.
Enhancements
- #514: Fix bugs in new image loader and make it default.
- #517: Cleaner separation of API result handling.
- #519: Better experience for new users.
- #520: Remove flash.
- #525: Properly generate the Video stream url, without actually making a request.
- #530: Use DiffUtil for better performance when refreshing the data.
- #532: Larger image cache.

View File

@ -0,0 +1,12 @@
Corrección de errores
- # 368: ID vacío en getCoverArt.
- # 528: Guardar listas de reproducción no funciona.
Mejoras
- # 514: Corregir errores en el nuevo cargador de imágenes y usarlo por defecto.
- # 517: Separación más limpia del manejo de resultados API.
- # 519: Mejor experiencia para nuevos usuarios.
- # 520: Quitar flash.
- # 525: Generar correctamente la URL de transmisión de video, sin realizar una nueva solicitud.
- # 530: Usar DiffUtil para un mejor rendimiento al actualizar los datos.
- # 532: Caché de imágenes más grande.

View File

@ -1,9 +1,11 @@
/**
* This module provides a base for for submodules which depend on the Android runtime
*/
apply plugin: 'com.android.library' apply plugin: 'com.android.library'
apply plugin: 'kotlin-android' apply plugin: 'kotlin-android'
apply plugin: 'jacoco' apply plugin: 'jacoco'
apply from: "${project.rootDir}/gradle_scripts/code_quality.gradle" apply from: "${project.rootDir}/gradle_scripts/code_quality.gradle"
android { android {
compileSdkVersion versions.compileSdk compileSdkVersion versions.compileSdk

View File

@ -1,5 +1,8 @@
apply plugin: 'java-library' /**
* This module provides a base for for pure kotlin modules
*/
apply plugin: 'kotlin' apply plugin: 'kotlin'
apply plugin: 'kotlin-kapt'
apply plugin: 'jacoco' apply plugin: 'jacoco'
apply from: "${project.rootDir}/gradle_scripts/code_quality.gradle" apply from: "${project.rootDir}/gradle_scripts/code_quality.gradle"

View File

@ -1,4 +1,3 @@
include ':core:domain' include ':core:domain'
include ':core:subsonic-api' include ':core:subsonic-api'
include ':core:cache'
include ':ultrasonic' include ':ultrasonic'

View File

@ -9,8 +9,8 @@ android {
defaultConfig { defaultConfig {
applicationId "org.moire.ultrasonic" applicationId "org.moire.ultrasonic"
versionCode 93 versionCode 94
versionName "2.21.0" versionName "2.22.0"
minSdkVersion versions.minSdk minSdkVersion versions.minSdk
targetSdkVersion versions.targetSdk targetSdkVersion versions.targetSdk
@ -61,6 +61,13 @@ android {
sourceCompatibility JavaVersion.VERSION_1_8 sourceCompatibility JavaVersion.VERSION_1_8
targetCompatibility JavaVersion.VERSION_1_8 targetCompatibility JavaVersion.VERSION_1_8
} }
kapt {
arguments {
arg("room.schemaLocation", "$buildDir/schemas".toString())
}
}
} }
tasks.withType(Test) { tasks.withType(Test) {
@ -70,7 +77,6 @@ tasks.withType(Test) {
dependencies { dependencies {
implementation project(':core:domain') implementation project(':core:domain')
implementation project(':core:subsonic-api') implementation project(':core:subsonic-api')
implementation project(':core:cache')
api(other.picasso) { api(other.picasso) {
exclude group: "com.android.support" exclude group: "com.android.support"

View File

@ -451,13 +451,6 @@
column="9"/> column="9"/>
</issue> </issue>
<issue
id="ObsoleteSdkInt"
message="This folder configuration (`v14`) is unnecessary; `minSdkVersion` is 14. Merge all the resources in this folder into `drawable-xhdpi`.">
<location
file="src/main/res/drawable-xhdpi-v14"/>
</issue>
<issue <issue
id="StaticFieldLeak" id="StaticFieldLeak"
message="This `AsyncTask` class should be static or leaks might occur (org.moire.ultrasonic.service.DownloadQueueSerializer.SerializeTask)" message="This `AsyncTask` class should be static or leaks might occur (org.moire.ultrasonic.service.DownloadQueueSerializer.SerializeTask)"
@ -480,17 +473,6 @@
column="19"/> column="19"/>
</issue> </issue>
<issue
id="StaticFieldLeak"
message="Do not place Android context classes in static fields; this is a memory leak"
errorLine1=" private static Context context;"
errorLine2=" ~~~~~~">
<location
file="src/main/java/org/moire/ultrasonic/view/UpdateView.java"
line="29"
column="10"/>
</issue>
<issue <issue
id="UseCompoundDrawables" id="UseCompoundDrawables"
message="This tag and its children can be replaced by one `&lt;TextView/>` and a compound drawable" message="This tag and its children can be replaced by one `&lt;TextView/>` and a compound drawable"

View File

@ -3,8 +3,6 @@
package="org.moire.ultrasonic" package="org.moire.ultrasonic"
android:installLocation="auto"> android:installLocation="auto">
<uses-sdk android:minSdkVersion="20" android:targetSdkVersion="29" />
<uses-permission android:name="android.permission.INTERNET"/> <uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/> <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>
<uses-permission android:name="android.permission.WAKE_LOCK"/> <uses-permission android:name="android.permission.WAKE_LOCK"/>

View File

@ -3,7 +3,6 @@ package org.moire.ultrasonic.view;
import android.content.Context; import android.content.Context;
import android.os.Handler; import android.os.Handler;
import android.os.Looper; import android.os.Looper;
import timber.log.Timber;
import android.view.ViewGroup; import android.view.ViewGroup;
import android.widget.AbsListView; import android.widget.AbsListView;
import android.widget.LinearLayout; import android.widget.LinearLayout;
@ -14,6 +13,8 @@ import java.util.ArrayList;
import java.util.Collection; import java.util.Collection;
import java.util.WeakHashMap; import java.util.WeakHashMap;
import timber.log.Timber;
/** /**
* A View that is periodically refreshed * A View that is periodically refreshed
* @deprecated * @deprecated
@ -26,12 +27,10 @@ public class UpdateView extends LinearLayout
private static Handler backgroundHandler; private static Handler backgroundHandler;
private static Handler uiHandler; private static Handler uiHandler;
private static Runnable updateRunnable; private static Runnable updateRunnable;
private static Context context;
public UpdateView(Context context) public UpdateView(Context context)
{ {
super(context); super(context);
UpdateView.context = context;
setLayoutParams(new AbsListView.LayoutParams(ViewGroup.LayoutParams.FILL_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)); setLayoutParams(new AbsListView.LayoutParams(ViewGroup.LayoutParams.FILL_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT));
INSTANCES.put(this, null); INSTANCES.put(this, null);

View File

@ -9,7 +9,6 @@ import org.moire.ultrasonic.BuildConfig
import org.moire.ultrasonic.di.appPermanentStorage import org.moire.ultrasonic.di.appPermanentStorage
import org.moire.ultrasonic.di.applicationModule import org.moire.ultrasonic.di.applicationModule
import org.moire.ultrasonic.di.baseNetworkModule import org.moire.ultrasonic.di.baseNetworkModule
import org.moire.ultrasonic.di.directoriesModule
import org.moire.ultrasonic.di.featureFlagsModule import org.moire.ultrasonic.di.featureFlagsModule
import org.moire.ultrasonic.di.mediaPlayerModule import org.moire.ultrasonic.di.mediaPlayerModule
import org.moire.ultrasonic.di.musicServiceModule import org.moire.ultrasonic.di.musicServiceModule
@ -46,7 +45,6 @@ class UApp : MultiDexApplication() {
// declare modules to use // declare modules to use
modules( modules(
applicationModule, applicationModule,
directoriesModule,
appPermanentStorage, appPermanentStorage,
baseNetworkModule, baseNetworkModule,
featureFlagsModule, featureFlagsModule,

View File

@ -1,17 +0,0 @@
package org.moire.ultrasonic.cache
import android.content.Context
import java.io.File
/**
* Provides specific to Android implementation of [Directories].
*/
class AndroidDirectories(
private val context: Context
) : Directories {
override fun getInternalCacheDir(): File = context.cacheDir
override fun getInternalDataDir(): File = context.filesDir
override fun getExternalCacheDir(): File? = context.externalCacheDir
}

View File

@ -1,5 +1,6 @@
package org.moire.ultrasonic.data package org.moire.ultrasonic.data
import androidx.room.Room
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@ -7,6 +8,7 @@ import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.moire.ultrasonic.R import org.moire.ultrasonic.R
import org.moire.ultrasonic.app.UApp import org.moire.ultrasonic.app.UApp
import org.moire.ultrasonic.di.DB_FILENAME
import org.moire.ultrasonic.service.MusicServiceFactory.resetMusicService import org.moire.ultrasonic.service.MusicServiceFactory.resetMusicService
import org.moire.ultrasonic.util.Constants import org.moire.ultrasonic.util.Constants
import org.moire.ultrasonic.util.Util import org.moire.ultrasonic.util.Util
@ -20,6 +22,8 @@ class ActiveServerProvider(
private val repository: ServerSettingDao private val repository: ServerSettingDao
) { ) {
private var cachedServer: ServerSetting? = null private var cachedServer: ServerSetting? = null
private var cachedDatabase: MetaDatabase? = null
private var cachedServerId: Int? = null
/** /**
* Get the settings of the current Active Server * Get the settings of the current Active Server
@ -82,6 +86,33 @@ class ActiveServerProvider(
} }
} }
@Synchronized
fun getActiveMetaDatabase(): MetaDatabase {
val activeServer = getActiveServerId()
if (activeServer == cachedServerId && cachedDatabase != null) {
return cachedDatabase!!
}
Timber.i("Switching to new database, id:$activeServer")
cachedServerId = activeServer
val db = Room.databaseBuilder(
UApp.applicationContext(),
MetaDatabase::class.java,
METADATA_DB + cachedServerId
)
.fallbackToDestructiveMigrationOnDowngrade()
.build()
return db
}
@Synchronized
fun deleteMetaDatabase(id: Int) {
cachedDatabase?.close()
UApp.applicationContext().deleteDatabase(METADATA_DB + id)
Timber.i("Deleted metadataBase, id:$id")
}
/** /**
* Sets the minimum Subsonic API version of the current server. * Sets the minimum Subsonic API version of the current server.
*/ */
@ -130,6 +161,9 @@ class ActiveServerProvider(
} }
companion object { companion object {
const val METADATA_DB = "$DB_FILENAME-meta-"
/** /**
* Queries if the Active Server is the "Offline" mode of Ultrasonic * Queries if the Active Server is the "Offline" mode of Ultrasonic
* @return True, if the "Offline" mode is selected * @return True, if the "Offline" mode is selected

View File

@ -6,7 +6,8 @@ import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase import androidx.sqlite.db.SupportSQLiteDatabase
/** /**
* Room Database to be used to store data for Ultrasonic * Room Database to be used to store global data for the whole app.
* This could be settings or data that are not specific to any remote music database
*/ */
@Database(entities = [ServerSetting::class], version = 3) @Database(entities = [ServerSetting::class], version = 3)
abstract class AppDatabase : RoomDatabase() { abstract class AppDatabase : RoomDatabase() {

View File

@ -0,0 +1,31 @@
package org.moire.ultrasonic.data
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import org.moire.ultrasonic.domain.Artist
@Dao
interface ArtistsDao {
/**
* Insert a list in the database. If the item already exists, replace it.
*
* @param objects the items to be inserted.
*/
@Insert(onConflict = OnConflictStrategy.REPLACE)
@JvmSuppressWildcards
fun set(objects: List<Artist>)
/**
* Clear the whole database
*/
@Query("DELETE FROM artists")
fun clear()
/**
* Get all artists
*/
@Query("SELECT * FROM artists")
fun get(): List<Artist>
}

View File

@ -0,0 +1,146 @@
package org.moire.ultrasonic.data
import androidx.room.Dao
import androidx.room.Delete
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import androidx.room.Transaction
import androidx.room.Update
import org.moire.ultrasonic.domain.Index
import org.moire.ultrasonic.domain.MusicFolder
@Dao
interface MusicFoldersDao : GenericDao<MusicFolder> {
/**
* Clear the whole database
*/
@Query("DELETE FROM music_folders")
fun clear()
/**
* Get all folders
*/
@Query("SELECT * FROM music_folders")
fun get(): List<MusicFolder>
}
@Dao
interface IndexDao : GenericDao<Index> {
/**
* Clear the whole database
*/
@Query("DELETE FROM indexes")
fun clear()
/**
* Get all indexes
*/
@Query("SELECT * FROM indexes")
fun get(): List<Index>
@Insert(onConflict = OnConflictStrategy.REPLACE)
fun insertAll(vararg indexes: Index)
@Insert(onConflict = OnConflictStrategy.REPLACE)
fun insertArray(arr: Array<Index>)
/**
* Get all indexes for a specific folder id
*/
@Query("SELECT * FROM indexes where musicFolderId LIKE :musicFolderId")
fun get(musicFolderId: String): List<Index>
/**
* Upserts (insert or update) an object to the database
*
* @param obj the object to upsert
*/
@Transaction
@JvmSuppressWildcards
fun upsert(obj: Index) {
val id = insertIgnoring(obj)
if (id == -1L) {
update(obj)
}
}
/**
* Upserts (insert or update) a list of objects
*
* @param objList the object to be upserted
*/
@Transaction
@JvmSuppressWildcards
fun upsert(objList: List<Index>) {
val insertResult = insertIgnoring(objList)
val updateList: MutableList<Index> = ArrayList()
for (i in insertResult.indices) {
if (insertResult[i] == -1L) {
updateList.add(objList[i])
}
}
if (updateList.isNotEmpty()) {
update(updateList)
}
}
}
interface GenericDao<T> {
/**
* Replaces the list with a new collection
*
* @param objects the items to be inserted.
*/
@Insert(onConflict = OnConflictStrategy.REPLACE)
@JvmSuppressWildcards
fun set(objects: List<T>)
/**
* Insert an object in the database.
*
* @param obj the object to be inserted.
* @return The SQLite row id
*/
@Insert(onConflict = OnConflictStrategy.IGNORE)
@JvmSuppressWildcards
fun insertIgnoring(obj: T): Long
/**
* Insert an array of objects in the database.
*
* @param obj the objects to be inserted.
* @return The SQLite row ids
*/
@Insert(onConflict = OnConflictStrategy.IGNORE)
@JvmSuppressWildcards
fun insertIgnoring(obj: List<T>?): List<Long>
/**
* Update an object from the database.
*
* @param obj the object to be updated
*/
@Update
@JvmSuppressWildcards
fun update(obj: T)
/**
* Update an array of objects from the database.
*
* @param obj the object to be updated
*/
@Update
@JvmSuppressWildcards
fun update(obj: List<T>?)
/**
* Delete an object from the database
*
* @param obj the object to be deleted
*/
@Delete
@JvmSuppressWildcards
fun delete(obj: T)
}

View File

@ -0,0 +1,19 @@
package org.moire.ultrasonic.data
import androidx.room.Database
import androidx.room.RoomDatabase
import org.moire.ultrasonic.domain.Artist
import org.moire.ultrasonic.domain.Index
import org.moire.ultrasonic.domain.MusicFolder
@Database(
entities = [Artist::class, Index::class, MusicFolder::class],
version = 1
)
abstract class MetaDatabase : RoomDatabase() {
abstract fun artistsDao(): ArtistsDao
abstract fun musicFoldersDao(): MusicFoldersDao
abstract fun indexDao(): IndexDao
}

View File

@ -1,13 +0,0 @@
package org.moire.ultrasonic.di
import org.koin.dsl.bind
import org.koin.dsl.module
import org.moire.ultrasonic.cache.AndroidDirectories
import org.moire.ultrasonic.cache.Directories
/**
* This Koin module contains the registration for Directories
*/
val directoriesModule = module {
single { AndroidDirectories(get()) } bind Directories::class
}

View File

@ -10,7 +10,6 @@ import org.moire.ultrasonic.BuildConfig
import org.moire.ultrasonic.api.subsonic.SubsonicAPIClient import org.moire.ultrasonic.api.subsonic.SubsonicAPIClient
import org.moire.ultrasonic.api.subsonic.SubsonicAPIVersions import org.moire.ultrasonic.api.subsonic.SubsonicAPIVersions
import org.moire.ultrasonic.api.subsonic.SubsonicClientConfiguration import org.moire.ultrasonic.api.subsonic.SubsonicClientConfiguration
import org.moire.ultrasonic.cache.PermanentFileStorage
import org.moire.ultrasonic.data.ActiveServerProvider import org.moire.ultrasonic.data.ActiveServerProvider
import org.moire.ultrasonic.imageloader.ImageLoader import org.moire.ultrasonic.imageloader.ImageLoader
import org.moire.ultrasonic.log.TimberOkHttpLogger import org.moire.ultrasonic.log.TimberOkHttpLogger
@ -43,11 +42,6 @@ val musicServiceModule = module {
return@single abs("$serverUrl$serverInstance".hashCode()).toString() return@single abs("$serverUrl$serverInstance".hashCode()).toString()
} }
single {
val serverId = get<String>(named("ServerID"))
return@single PermanentFileStorage(get(), serverId, BuildConfig.DEBUG)
}
single { single {
val server = get<ActiveServerProvider>().getActiveServer() val server = get<ActiveServerProvider>().getActiveServer()
@ -71,7 +65,7 @@ val musicServiceModule = module {
single { SubsonicAPIClient(get(), get()) } single { SubsonicAPIClient(get(), get()) }
single<MusicService>(named(ONLINE_MUSIC_SERVICE)) { single<MusicService>(named(ONLINE_MUSIC_SERVICE)) {
CachedMusicService(RESTMusicService(get(), get(), get())) CachedMusicService(RESTMusicService(get(), get()))
} }
single<MusicService>(named(OFFLINE_MUSIC_SERVICE)) { single<MusicService>(named(OFFLINE_MUSIC_SERVICE)) {

View File

@ -5,12 +5,20 @@ package org.moire.ultrasonic.domain
import org.moire.ultrasonic.api.subsonic.models.Artist as APIArtist import org.moire.ultrasonic.api.subsonic.models.Artist as APIArtist
// When we like to convert to an Artist
fun APIArtist.toDomainEntity(): Artist = Artist( fun APIArtist.toDomainEntity(): Artist = Artist(
id = this@toDomainEntity.id, id = this@toDomainEntity.id,
coverArt = this@toDomainEntity.coverArt, coverArt = this@toDomainEntity.coverArt,
name = this@toDomainEntity.name name = this@toDomainEntity.name
) )
// When we like to convert to an index (eg. a single directory).
fun APIArtist.toIndexEntity(): Index = Index(
id = this@toIndexEntity.id,
coverArt = this@toIndexEntity.coverArt,
name = this@toIndexEntity.name
)
fun APIArtist.toMusicDirectoryDomainEntity(): MusicDirectory = MusicDirectory().apply { fun APIArtist.toMusicDirectoryDomainEntity(): MusicDirectory = MusicDirectory().apply {
name = this@toMusicDirectoryDomainEntity.name name = this@toMusicDirectoryDomainEntity.name
addAll(this@toMusicDirectoryDomainEntity.albumsList.map { it.toDomainEntity() }) addAll(this@toMusicDirectoryDomainEntity.albumsList.map { it.toDomainEntity() })

View File

@ -3,15 +3,51 @@
@file:JvmName("APIIndexesConverter") @file:JvmName("APIIndexesConverter")
package org.moire.ultrasonic.domain package org.moire.ultrasonic.domain
import org.moire.ultrasonic.api.subsonic.models.Index import org.moire.ultrasonic.api.subsonic.models.Index as APIIndex
import org.moire.ultrasonic.api.subsonic.models.Indexes as APIIndexes import org.moire.ultrasonic.api.subsonic.models.Indexes as APIIndexes
fun APIIndexes.toDomainEntity(): Indexes = Indexes( fun APIIndexes.toArtistList(): List<Artist> {
this.lastModified, this.ignoredArticles, val shortcuts = this.shortcutList.map { it.toDomainEntity() }.toMutableList()
this.shortcutList.map { it.toDomainEntity() }.toMutableList(), val indexes = this.indexList.foldIndexToArtistList()
this.indexList.foldIndexToArtistList().toMutableList()
indexes.forEach {
if (!shortcuts.contains(it)) {
shortcuts.add(it)
}
}
return shortcuts
}
fun APIIndexes.toIndexList(musicFolderId: String?): List<Index> {
val shortcuts = this.shortcutList.map { it.toIndexEntity() }.toMutableList()
val indexes = this.indexList.foldIndexToIndexList(musicFolderId)
indexes.forEach {
if (!shortcuts.contains(it)) {
shortcuts.add(it)
}
}
return shortcuts
}
private fun List<APIIndex>.foldIndexToArtistList(): List<Artist> = this.fold(
listOf(),
{ acc, index ->
acc + index.artists.map {
it.toDomainEntity()
}
}
) )
private fun List<Index>.foldIndexToArtistList(): List<Artist> = this.fold( private fun List<APIIndex>.foldIndexToIndexList(musicFolderId: String?): List<Index> = this.fold(
listOf(), { acc, index -> acc + index.artists.map { it.toDomainEntity() } } listOf(),
{ acc, index ->
acc + index.artists.map {
val ret = it.toIndexEntity()
ret.musicFolderId = musicFolderId
ret
}
}
) )

View File

@ -4,13 +4,13 @@ import android.os.Bundle
import androidx.fragment.app.viewModels import androidx.fragment.app.viewModels
import androidx.lifecycle.LiveData import androidx.lifecycle.LiveData
import org.moire.ultrasonic.R import org.moire.ultrasonic.R
import org.moire.ultrasonic.domain.Artist import org.moire.ultrasonic.domain.ArtistOrIndex
import org.moire.ultrasonic.util.Constants import org.moire.ultrasonic.util.Constants
/** /**
* Displays the list of Artists from the media library * Displays the list of Artists from the media library
*/ */
class ArtistListFragment : GenericListFragment<Artist, ArtistRowAdapter>() { class ArtistListFragment : GenericListFragment<ArtistOrIndex, ArtistRowAdapter>() {
/** /**
* The ViewModel to use to get the data * The ViewModel to use to get the data
@ -41,7 +41,7 @@ class ArtistListFragment : GenericListFragment<Artist, ArtistRowAdapter>() {
/** /**
* The central function to pass a query to the model and return a LiveData object * The central function to pass a query to the model and return a LiveData object
*/ */
override fun getLiveData(args: Bundle?): LiveData<List<Artist>> { override fun getLiveData(args: Bundle?): LiveData<List<ArtistOrIndex>> {
val refresh = args?.getBoolean(Constants.INTENT_EXTRA_NAME_REFRESH) ?: false val refresh = args?.getBoolean(Constants.INTENT_EXTRA_NAME_REFRESH) ?: false
return listModel.getItems(refresh, refreshListView!!) return listModel.getItems(refresh, refreshListView!!)
} }

View File

@ -23,19 +23,19 @@ import android.os.Bundle
import androidx.lifecycle.LiveData import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData import androidx.lifecycle.MutableLiveData
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
import org.moire.ultrasonic.domain.Artist import org.moire.ultrasonic.domain.ArtistOrIndex
import org.moire.ultrasonic.service.MusicService import org.moire.ultrasonic.service.MusicService
/** /**
* Provides ViewModel which contains the list of available Artists * Provides ViewModel which contains the list of available Artists
*/ */
class ArtistListModel(application: Application) : GenericListModel(application) { class ArtistListModel(application: Application) : GenericListModel(application) {
val artists: MutableLiveData<List<Artist>> = MutableLiveData(listOf()) val artists: MutableLiveData<List<ArtistOrIndex>> = MutableLiveData(listOf())
/** /**
* Retrieves all available Artists in a LiveData * Retrieves all available Artists in a LiveData
*/ */
fun getItems(refresh: Boolean, swipe: SwipeRefreshLayout?): LiveData<List<Artist>> { fun getItems(refresh: Boolean, swipe: SwipeRefreshLayout?): LiveData<List<ArtistOrIndex>> {
// Don't reload the data if navigating back to the view that was active before. // Don't reload the data if navigating back to the view that was active before.
// This way, we keep the scroll position // This way, we keep the scroll position
if (artists.value!!.isEmpty() || refresh) { if (artists.value!!.isEmpty() || refresh) {
@ -55,14 +55,14 @@ class ArtistListModel(application: Application) : GenericListModel(application)
val musicFolderId = activeServer.musicFolderId val musicFolderId = activeServer.musicFolderId
val result = if (!isOffline && useId3Tags) val result: List<ArtistOrIndex>
musicService.getArtists(refresh)
else musicService.getIndexes(musicFolderId, refresh)
val retrievedArtists: MutableList<Artist> = if (!isOffline && useId3Tags) {
ArrayList(result.shortcuts.size + result.artists.size) result = musicService.getArtists(refresh)
retrievedArtists.addAll(result.shortcuts) } else {
retrievedArtists.addAll(result.artists) result = musicService.getIndexes(musicFolderId, refresh)
artists.postValue(retrievedArtists) }
artists.postValue(result.toMutableList())
} }
} }

View File

@ -13,7 +13,7 @@ import androidx.recyclerview.widget.RecyclerView
import com.simplecityapps.recyclerview_fastscroll.views.FastScrollRecyclerView.SectionedAdapter import com.simplecityapps.recyclerview_fastscroll.views.FastScrollRecyclerView.SectionedAdapter
import java.text.Collator import java.text.Collator
import org.moire.ultrasonic.R import org.moire.ultrasonic.R
import org.moire.ultrasonic.domain.Artist import org.moire.ultrasonic.domain.ArtistOrIndex
import org.moire.ultrasonic.domain.MusicDirectory import org.moire.ultrasonic.domain.MusicDirectory
import org.moire.ultrasonic.imageloader.ImageLoader import org.moire.ultrasonic.imageloader.ImageLoader
import org.moire.ultrasonic.util.Util import org.moire.ultrasonic.util.Util
@ -22,12 +22,12 @@ import org.moire.ultrasonic.util.Util
* Creates a Row in a RecyclerView which contains the details of an Artist * Creates a Row in a RecyclerView which contains the details of an Artist
*/ */
class ArtistRowAdapter( class ArtistRowAdapter(
artistList: List<Artist>, artistList: List<ArtistOrIndex>,
onItemClick: (Artist) -> Unit, onItemClick: (ArtistOrIndex) -> Unit,
onContextMenuClick: (MenuItem, Artist) -> Boolean, onContextMenuClick: (MenuItem, ArtistOrIndex) -> Boolean,
private val imageLoader: ImageLoader, private val imageLoader: ImageLoader,
onMusicFolderUpdate: (String?) -> Unit onMusicFolderUpdate: (String?) -> Unit
) : GenericRowAdapter<Artist>( ) : GenericRowAdapter<ArtistOrIndex>(
onItemClick, onItemClick,
onContextMenuClick, onContextMenuClick,
onMusicFolderUpdate onMusicFolderUpdate
@ -43,7 +43,7 @@ class ArtistRowAdapter(
/** /**
* Sets the data to be displayed in the RecyclerView * Sets the data to be displayed in the RecyclerView
*/ */
override fun setData(data: List<Artist>) { override fun setData(data: List<ArtistOrIndex>) {
itemList = data.sortedWith(compareBy(Collator.getInstance()) { t -> t.name }) itemList = data.sortedWith(compareBy(Collator.getInstance()) { t -> t.name })
super.notifyDataSetChanged() super.notifyDataSetChanged()
} }

View File

@ -45,7 +45,7 @@ open class GenericListModel(application: Application) :
return true return true
} }
internal val musicFolders: MutableLiveData<List<MusicFolder>> = MutableLiveData() internal val musicFolders: MutableLiveData<List<MusicFolder>> = MutableLiveData(listOf())
/** /**
* Helper function to check online status * Helper function to check online status

View File

@ -8,7 +8,6 @@ import android.view.ViewGroup
import android.widget.AdapterView import android.widget.AdapterView
import android.widget.ListView import android.widget.ListView
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import androidx.lifecycle.Observer
import androidx.navigation.fragment.findNavController import androidx.navigation.fragment.findNavController
import com.google.android.material.floatingactionbutton.FloatingActionButton import com.google.android.material.floatingactionbutton.FloatingActionButton
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@ -104,7 +103,7 @@ class ServerSelectorFragment : Fragment() {
val serverList = serverSettingsModel.getServerList() val serverList = serverSettingsModel.getServerList()
serverList.observe( serverList.observe(
this, this,
Observer { t -> { t ->
serverRowAdapter!!.setData(t.toTypedArray()) serverRowAdapter!!.setData(t.toTypedArray())
} }
) )
@ -141,10 +140,16 @@ class ServerSelectorFragment : Fragment() {
dialog.dismiss() dialog.dismiss()
val activeServerIndex = activeServerProvider.getActiveServer().index val activeServerIndex = activeServerProvider.getActiveServer().index
val id = ActiveServerProvider.getActiveServerId()
// If the currently active server is deleted, go offline // If the currently active server is deleted, go offline
if (index == activeServerIndex) setActiveServer(-1) if (index == activeServerIndex) setActiveServer(-1)
serverSettingsModel.deleteItem(index) serverSettingsModel.deleteItem(index)
// Clear the metadata cache
activeServerProvider.deleteMetaDatabase(id)
Timber.i("Server deleted: $index") Timber.i("Server deleted: $index")
} }
.setNegativeButton(R.string.common_cancel) { dialog, _ -> .setNegativeButton(R.string.common_cancel) { dialog, _ ->

View File

@ -1,9 +1,13 @@
package org.moire.ultrasonic.imageloader package org.moire.ultrasonic.imageloader
import android.app.ActivityManager
import android.content.Context import android.content.Context
import android.content.pm.ApplicationInfo
import android.text.TextUtils import android.text.TextUtils
import android.view.View import android.view.View
import android.widget.ImageView import android.widget.ImageView
import androidx.core.content.ContextCompat
import com.squareup.picasso.LruCache
import com.squareup.picasso.Picasso import com.squareup.picasso.Picasso
import com.squareup.picasso.RequestCreator import com.squareup.picasso.RequestCreator
import java.io.File import java.io.File
@ -35,6 +39,7 @@ class ImageLoader(
private val picasso = Picasso.Builder(context) private val picasso = Picasso.Builder(context)
.addRequestHandler(CoverArtRequestHandler(apiClient)) .addRequestHandler(CoverArtRequestHandler(apiClient))
.addRequestHandler(AvatarRequestHandler(apiClient)) .addRequestHandler(AvatarRequestHandler(apiClient))
.memoryCache(LruCache(calculateMemoryCacheSize(context)))
.build().apply { .build().apply {
setIndicatorsEnabled(BuildConfig.DEBUG) setIndicatorsEnabled(BuildConfig.DEBUG)
} }
@ -179,6 +184,18 @@ class ImageLoader(
return requested return requested
} }
} }
private fun calculateMemoryCacheSize(context: Context): Int {
val am = ContextCompat.getSystemService(
context,
ActivityManager::class.java
)
val largeHeap = context.applicationInfo.flags and ApplicationInfo.FLAG_LARGE_HEAP != 0
val memoryClass = if (largeHeap) am!!.largeMemoryClass else am!!.memoryClass
// Target 25% of the available heap.
@Suppress("MagicNumber")
return (1024L * 1024L * memoryClass / 4).toInt()
}
} }
/** /**

View File

@ -11,10 +11,12 @@ import java.util.concurrent.TimeUnit
import org.koin.core.component.KoinComponent import org.koin.core.component.KoinComponent
import org.koin.core.component.inject import org.koin.core.component.inject
import org.moire.ultrasonic.data.ActiveServerProvider import org.moire.ultrasonic.data.ActiveServerProvider
import org.moire.ultrasonic.data.MetaDatabase
import org.moire.ultrasonic.domain.Artist
import org.moire.ultrasonic.domain.Bookmark import org.moire.ultrasonic.domain.Bookmark
import org.moire.ultrasonic.domain.ChatMessage import org.moire.ultrasonic.domain.ChatMessage
import org.moire.ultrasonic.domain.Genre import org.moire.ultrasonic.domain.Genre
import org.moire.ultrasonic.domain.Indexes import org.moire.ultrasonic.domain.Index
import org.moire.ultrasonic.domain.JukeboxStatus import org.moire.ultrasonic.domain.JukeboxStatus
import org.moire.ultrasonic.domain.Lyrics import org.moire.ultrasonic.domain.Lyrics
import org.moire.ultrasonic.domain.MusicDirectory import org.moire.ultrasonic.domain.MusicDirectory
@ -33,19 +35,24 @@ import org.moire.ultrasonic.util.Util
@Suppress("TooManyFunctions") @Suppress("TooManyFunctions")
class CachedMusicService(private val musicService: MusicService) : MusicService, KoinComponent { class CachedMusicService(private val musicService: MusicService) : MusicService, KoinComponent {
private val activeServerProvider: ActiveServerProvider by inject() private val activeServerProvider: ActiveServerProvider by inject()
private var metaDatabase: MetaDatabase = activeServerProvider.getActiveMetaDatabase()
// Old style TimeLimitedCache
private val cachedMusicDirectories: LRUCache<String, TimeLimitedCache<MusicDirectory?>>
private val cachedArtist: LRUCache<String, TimeLimitedCache<MusicDirectory?>>
private val cachedAlbum: LRUCache<String, TimeLimitedCache<MusicDirectory?>>
private val cachedUserInfo: LRUCache<String, TimeLimitedCache<UserInfo?>>
private val cachedLicenseValid = TimeLimitedCache<Boolean>(120, TimeUnit.SECONDS)
private val cachedPlaylists = TimeLimitedCache<List<Playlist>?>(3600, TimeUnit.SECONDS)
private val cachedPodcastsChannels =
TimeLimitedCache<List<PodcastsChannel>?>(3600, TimeUnit.SECONDS)
private val cachedGenres = TimeLimitedCache<List<Genre>>(10 * 3600, TimeUnit.SECONDS)
// New Room Database
private var cachedArtists = metaDatabase.artistsDao()
private var cachedIndexes = metaDatabase.indexDao()
private val cachedMusicFolders = metaDatabase.musicFoldersDao()
private val cachedMusicDirectories: LRUCache<String?, TimeLimitedCache<MusicDirectory?>>
private val cachedArtist: LRUCache<String?, TimeLimitedCache<MusicDirectory?>>
private val cachedAlbum: LRUCache<String?, TimeLimitedCache<MusicDirectory?>>
private val cachedUserInfo: LRUCache<String?, TimeLimitedCache<UserInfo?>>
private val cachedLicenseValid = TimeLimitedCache<Boolean>(expiresAfter = 10, TimeUnit.MINUTES)
private val cachedIndexes = TimeLimitedCache<Indexes?>()
private val cachedArtists = TimeLimitedCache<Indexes?>()
private val cachedPlaylists = TimeLimitedCache<List<Playlist>?>()
private val cachedPodcastsChannels = TimeLimitedCache<List<PodcastsChannel>>()
private val cachedMusicFolders =
TimeLimitedCache<List<MusicFolder>?>(10, TimeUnit.HOURS)
private val cachedGenres = TimeLimitedCache<List<Genre>?>(10, TimeUnit.HOURS)
private var restUrl: String? = null private var restUrl: String? = null
private var cachedMusicFolderId: String? = null private var cachedMusicFolderId: String? = null
@ -72,41 +79,51 @@ class CachedMusicService(private val musicService: MusicService) : MusicService,
if (refresh) { if (refresh) {
cachedMusicFolders.clear() cachedMusicFolders.clear()
} }
var result = cachedMusicFolders.get()
val cache = cachedMusicFolders.get() if (result.isEmpty()) {
if (cache != null) return cache result = musicService.getMusicFolders(refresh)
cachedMusicFolders.set(result)
val result = musicService.getMusicFolders(refresh) }
cachedMusicFolders.set(result)
return result return result
} }
@Throws(Exception::class) @Throws(Exception::class)
override fun getIndexes(musicFolderId: String?, refresh: Boolean): Indexes { override fun getIndexes(musicFolderId: String?, refresh: Boolean): List<Index> {
checkSettingsChanged() checkSettingsChanged()
if (refresh) { if (refresh) {
cachedIndexes.clear() cachedIndexes.clear()
cachedMusicFolders.clear()
cachedMusicDirectories.clear() cachedMusicDirectories.clear()
} }
var result = cachedIndexes.get()
if (result == null) { var indexes: List<Index>
result = musicService.getIndexes(musicFolderId, refresh)
cachedIndexes.set(result) if (musicFolderId == null) {
indexes = cachedIndexes.get()
} else {
indexes = cachedIndexes.get(musicFolderId)
} }
return result
if (indexes.isEmpty()) {
indexes = musicService.getIndexes(musicFolderId, refresh)
cachedIndexes.upsert(indexes)
}
return indexes
} }
@Throws(Exception::class) @Throws(Exception::class)
override fun getArtists(refresh: Boolean): Indexes { override fun getArtists(refresh: Boolean): List<Artist> {
checkSettingsChanged() checkSettingsChanged()
if (refresh) { if (refresh) {
cachedArtists.clear() cachedArtists.clear()
} }
var result = cachedArtists.get() var result = cachedArtists.get()
if (result == null) {
if (result.isEmpty()) {
result = musicService.getArtists(refresh) result = musicService.getArtists(refresh)
cachedArtist.clear()
cachedArtists.set(result) cachedArtists.set(result)
} }
return result return result
@ -296,19 +313,26 @@ class CachedMusicService(private val musicService: MusicService) : MusicService,
return musicService.setJukeboxGain(gain) return musicService.setJukeboxGain(gain)
} }
@Synchronized
private fun checkSettingsChanged() { private fun checkSettingsChanged() {
val newUrl = activeServerProvider.getRestUrl(null) val newUrl = activeServerProvider.getRestUrl(null)
val newFolderId = activeServerProvider.getActiveServer().musicFolderId val newFolderId = activeServerProvider.getActiveServer().musicFolderId
if (!Util.equals(newUrl, restUrl) || !Util.equals(cachedMusicFolderId, newFolderId)) { if (!Util.equals(newUrl, restUrl) || !Util.equals(cachedMusicFolderId, newFolderId)) {
cachedMusicFolders.clear() // Switch database
metaDatabase = activeServerProvider.getActiveMetaDatabase()
cachedArtists = metaDatabase.artistsDao()
cachedIndexes = metaDatabase.indexDao()
// Clear in memory caches
cachedMusicDirectories.clear() cachedMusicDirectories.clear()
cachedLicenseValid.clear() cachedLicenseValid.clear()
cachedIndexes.clear()
cachedPlaylists.clear() cachedPlaylists.clear()
cachedGenres.clear() cachedGenres.clear()
cachedAlbum.clear() cachedAlbum.clear()
cachedArtist.clear() cachedArtist.clear()
cachedUserInfo.clear() cachedUserInfo.clear()
// Set the cache keys
restUrl = newUrl restUrl = newUrl
cachedMusicFolderId = newFolderId cachedMusicFolderId = newFolderId
} }
@ -330,7 +354,7 @@ class CachedMusicService(private val musicService: MusicService) : MusicService,
} }
@Throws(Exception::class) @Throws(Exception::class)
override fun getGenres(refresh: Boolean): List<Genre>? { override fun getGenres(refresh: Boolean): List<Genre> {
checkSettingsChanged() checkSettingsChanged()
if (refresh) { if (refresh) {
cachedGenres.clear() cachedGenres.clear()
@ -338,11 +362,11 @@ class CachedMusicService(private val musicService: MusicService) : MusicService,
var result = cachedGenres.get() var result = cachedGenres.get()
if (result == null) { if (result == null) {
result = musicService.getGenres(refresh) result = musicService.getGenres(refresh)
cachedGenres.set(result) cachedGenres.set(result!!)
} }
val sorted = result?.toMutableList() val sorted = result.toMutableList()
sorted?.sortWith { genre, genre2 -> sorted.sortWith { genre, genre2 ->
genre.name.compareTo( genre.name.compareTo(
genre2.name, genre2.name,
ignoreCase = true ignoreCase = true

View File

@ -7,10 +7,11 @@
package org.moire.ultrasonic.service package org.moire.ultrasonic.service
import java.io.InputStream import java.io.InputStream
import org.moire.ultrasonic.domain.Artist
import org.moire.ultrasonic.domain.Bookmark import org.moire.ultrasonic.domain.Bookmark
import org.moire.ultrasonic.domain.ChatMessage import org.moire.ultrasonic.domain.ChatMessage
import org.moire.ultrasonic.domain.Genre import org.moire.ultrasonic.domain.Genre
import org.moire.ultrasonic.domain.Indexes import org.moire.ultrasonic.domain.Index
import org.moire.ultrasonic.domain.JukeboxStatus import org.moire.ultrasonic.domain.JukeboxStatus
import org.moire.ultrasonic.domain.Lyrics import org.moire.ultrasonic.domain.Lyrics
import org.moire.ultrasonic.domain.MusicDirectory import org.moire.ultrasonic.domain.MusicDirectory
@ -46,10 +47,10 @@ interface MusicService {
fun getMusicFolders(refresh: Boolean): List<MusicFolder> fun getMusicFolders(refresh: Boolean): List<MusicFolder>
@Throws(Exception::class) @Throws(Exception::class)
fun getIndexes(musicFolderId: String?, refresh: Boolean): Indexes fun getIndexes(musicFolderId: String?, refresh: Boolean): List<Index>
@Throws(Exception::class) @Throws(Exception::class)
fun getArtists(refresh: Boolean): Indexes fun getArtists(refresh: Boolean): List<Artist>
@Throws(Exception::class) @Throws(Exception::class)
fun getMusicDirectory(id: String, name: String?, refresh: Boolean): MusicDirectory fun getMusicDirectory(id: String, name: String?, refresh: Boolean): MusicDirectory

View File

@ -28,7 +28,7 @@ import org.moire.ultrasonic.domain.Artist
import org.moire.ultrasonic.domain.Bookmark import org.moire.ultrasonic.domain.Bookmark
import org.moire.ultrasonic.domain.ChatMessage import org.moire.ultrasonic.domain.ChatMessage
import org.moire.ultrasonic.domain.Genre import org.moire.ultrasonic.domain.Genre
import org.moire.ultrasonic.domain.Indexes import org.moire.ultrasonic.domain.Index
import org.moire.ultrasonic.domain.JukeboxStatus import org.moire.ultrasonic.domain.JukeboxStatus
import org.moire.ultrasonic.domain.Lyrics import org.moire.ultrasonic.domain.Lyrics
import org.moire.ultrasonic.domain.MusicDirectory import org.moire.ultrasonic.domain.MusicDirectory
@ -50,21 +50,21 @@ import timber.log.Timber
class OfflineMusicService : MusicService, KoinComponent { class OfflineMusicService : MusicService, KoinComponent {
private val activeServerProvider: ActiveServerProvider by inject() private val activeServerProvider: ActiveServerProvider by inject()
override fun getIndexes(musicFolderId: String?, refresh: Boolean): Indexes { override fun getIndexes(musicFolderId: String?, refresh: Boolean): List<Index> {
val artists: MutableList<Artist> = ArrayList() val indexes: MutableList<Index> = ArrayList()
val root = FileUtil.getMusicDirectory() val root = FileUtil.getMusicDirectory()
for (file in FileUtil.listFiles(root)) { for (file in FileUtil.listFiles(root)) {
if (file.isDirectory) { if (file.isDirectory) {
val artist = Artist() val index = Index(file.path)
artist.id = file.path index.id = file.path
artist.index = file.name.substring(0, 1) index.index = file.name.substring(0, 1)
artist.name = file.name index.name = file.name
artists.add(artist) indexes.add(index)
} }
} }
val ignoredArticlesString = "The El La Los Las Le Les" val ignoredArticlesString = "The El La Los Las Le Les"
val ignoredArticles = COMPILE.split(ignoredArticlesString) val ignoredArticles = COMPILE.split(ignoredArticlesString)
artists.sortWith { lhsArtist, rhsArtist -> indexes.sortWith { lhsArtist, rhsArtist ->
var lhs = lhsArtist.name!!.lowercase(Locale.ROOT) var lhs = lhsArtist.name!!.lowercase(Locale.ROOT)
var rhs = rhsArtist.name!!.lowercase(Locale.ROOT) var rhs = rhsArtist.name!!.lowercase(Locale.ROOT)
val lhs1 = lhs[0] val lhs1 = lhs[0]
@ -92,7 +92,7 @@ class OfflineMusicService : MusicService, KoinComponent {
lhs.compareTo(rhs) lhs.compareTo(rhs)
} }
return Indexes(0L, ignoredArticlesString, artists = artists) return indexes
} }
override fun getMusicDirectory( override fun getMusicDirectory(
@ -127,8 +127,7 @@ class OfflineMusicService : MusicService, KoinComponent {
val artistName = artistFile.name val artistName = artistFile.name
if (artistFile.isDirectory) { if (artistFile.isDirectory) {
if (matchCriteria(criteria, artistName).also { closeness = it } > 0) { if (matchCriteria(criteria, artistName).also { closeness = it } > 0) {
val artist = Artist() val artist = Artist(artistFile.path)
artist.id = artistFile.path
artist.index = artistFile.name.substring(0, 1) artist.index = artistFile.name.substring(0, 1)
artist.name = artistName artist.name = artistName
artist.closeness = closeness artist.closeness = closeness
@ -442,7 +441,7 @@ class OfflineMusicService : MusicService, KoinComponent {
override fun isLicenseValid(): Boolean = true override fun isLicenseValid(): Boolean = true
@Throws(OfflineException::class) @Throws(OfflineException::class)
override fun getArtists(refresh: Boolean): Indexes { override fun getArtists(refresh: Boolean): List<Artist> {
throw OfflineException("getArtists isn't available in offline mode") throw OfflineException("getArtists isn't available in offline mode")
} }

View File

@ -6,9 +6,6 @@
*/ */
package org.moire.ultrasonic.service package org.moire.ultrasonic.service
import java.io.BufferedWriter
import java.io.File
import java.io.FileWriter
import java.io.IOException import java.io.IOException
import java.io.InputStream import java.io.InputStream
import okhttp3.Protocol import okhttp3.Protocol
@ -20,15 +17,13 @@ import org.moire.ultrasonic.api.subsonic.models.AlbumListType.Companion.fromName
import org.moire.ultrasonic.api.subsonic.models.JukeboxAction import org.moire.ultrasonic.api.subsonic.models.JukeboxAction
import org.moire.ultrasonic.api.subsonic.throwOnFailure import org.moire.ultrasonic.api.subsonic.throwOnFailure
import org.moire.ultrasonic.api.subsonic.toStreamResponse import org.moire.ultrasonic.api.subsonic.toStreamResponse
import org.moire.ultrasonic.cache.PermanentFileStorage
import org.moire.ultrasonic.cache.serializers.getIndexesSerializer
import org.moire.ultrasonic.cache.serializers.getMusicFolderListSerializer
import org.moire.ultrasonic.data.ActiveServerProvider import org.moire.ultrasonic.data.ActiveServerProvider
import org.moire.ultrasonic.data.ActiveServerProvider.Companion.isOffline import org.moire.ultrasonic.data.ActiveServerProvider.Companion.isOffline
import org.moire.ultrasonic.domain.Artist
import org.moire.ultrasonic.domain.Bookmark import org.moire.ultrasonic.domain.Bookmark
import org.moire.ultrasonic.domain.ChatMessage import org.moire.ultrasonic.domain.ChatMessage
import org.moire.ultrasonic.domain.Genre import org.moire.ultrasonic.domain.Genre
import org.moire.ultrasonic.domain.Indexes import org.moire.ultrasonic.domain.Index
import org.moire.ultrasonic.domain.JukeboxStatus import org.moire.ultrasonic.domain.JukeboxStatus
import org.moire.ultrasonic.domain.Lyrics import org.moire.ultrasonic.domain.Lyrics
import org.moire.ultrasonic.domain.MusicDirectory import org.moire.ultrasonic.domain.MusicDirectory
@ -39,11 +34,14 @@ import org.moire.ultrasonic.domain.SearchCriteria
import org.moire.ultrasonic.domain.SearchResult import org.moire.ultrasonic.domain.SearchResult
import org.moire.ultrasonic.domain.Share import org.moire.ultrasonic.domain.Share
import org.moire.ultrasonic.domain.UserInfo import org.moire.ultrasonic.domain.UserInfo
import org.moire.ultrasonic.domain.toArtistList
import org.moire.ultrasonic.domain.toDomainEntitiesList import org.moire.ultrasonic.domain.toDomainEntitiesList
import org.moire.ultrasonic.domain.toDomainEntity import org.moire.ultrasonic.domain.toDomainEntity
import org.moire.ultrasonic.domain.toDomainEntityList import org.moire.ultrasonic.domain.toDomainEntityList
import org.moire.ultrasonic.domain.toIndexList
import org.moire.ultrasonic.domain.toMusicDirectoryDomainEntity import org.moire.ultrasonic.domain.toMusicDirectoryDomainEntity
import org.moire.ultrasonic.util.FileUtil import org.moire.ultrasonic.util.FileUtil
import org.moire.ultrasonic.util.FileUtilKt
import org.moire.ultrasonic.util.Util import org.moire.ultrasonic.util.Util
import timber.log.Timber import timber.log.Timber
@ -53,7 +51,6 @@ import timber.log.Timber
@Suppress("LargeClass") @Suppress("LargeClass")
open class RESTMusicService( open class RESTMusicService(
val subsonicAPIClient: SubsonicAPIClient, val subsonicAPIClient: SubsonicAPIClient,
private val fileStorage: PermanentFileStorage,
private val activeServerProvider: ActiveServerProvider private val activeServerProvider: ActiveServerProvider
) : MusicService { ) : MusicService {
@ -77,49 +74,31 @@ open class RESTMusicService(
override fun getMusicFolders( override fun getMusicFolders(
refresh: Boolean refresh: Boolean
): List<MusicFolder> { ): List<MusicFolder> {
val cachedMusicFolders = fileStorage.load(
MUSIC_FOLDER_STORAGE_NAME, getMusicFolderListSerializer()
)
if (cachedMusicFolders != null && !refresh) return cachedMusicFolders
val response = API.getMusicFolders().execute().throwOnFailure() val response = API.getMusicFolders().execute().throwOnFailure()
val musicFolders = response.body()!!.musicFolders.toDomainEntityList() return response.body()!!.musicFolders.toDomainEntityList()
fileStorage.store(MUSIC_FOLDER_STORAGE_NAME, musicFolders, getMusicFolderListSerializer())
return musicFolders
} }
/**
* Retrieves the artists for a given music folder *
*/
@Throws(Exception::class) @Throws(Exception::class)
override fun getIndexes( override fun getIndexes(
musicFolderId: String?, musicFolderId: String?,
refresh: Boolean refresh: Boolean
): Indexes { ): List<Index> {
val indexName = INDEXES_STORAGE_NAME + (musicFolderId ?: "")
val cachedIndexes = fileStorage.load(indexName, getIndexesSerializer())
if (cachedIndexes != null && !refresh) return cachedIndexes
val response = API.getIndexes(musicFolderId, null).execute().throwOnFailure() val response = API.getIndexes(musicFolderId, null).execute().throwOnFailure()
val indexes = response.body()!!.indexes.toDomainEntity() return response.body()!!.indexes.toIndexList(musicFolderId)
fileStorage.store(indexName, indexes, getIndexesSerializer())
return indexes
} }
@Throws(Exception::class) @Throws(Exception::class)
override fun getArtists( override fun getArtists(
refresh: Boolean refresh: Boolean
): Indexes { ): List<Artist> {
val cachedArtists = fileStorage.load(ARTISTS_STORAGE_NAME, getIndexesSerializer())
if (cachedArtists != null && !refresh) return cachedArtists
val response = API.getArtists(null).execute().throwOnFailure() val response = API.getArtists(null).execute().throwOnFailure()
val indexes = response.body()!!.indexes.toDomainEntity() return response.body()!!.indexes.toArtistList()
fileStorage.store(ARTISTS_STORAGE_NAME, indexes, getIndexesSerializer())
return indexes
} }
@Throws(Exception::class) @Throws(Exception::class)
@ -186,11 +165,11 @@ open class RESTMusicService(
criteria: SearchCriteria criteria: SearchCriteria
): SearchResult { ): SearchResult {
return try { return try {
if ( if (!isOffline() && Util.getShouldUseId3Tags()) {
!isOffline() && search3(criteria)
Util.getShouldUseId3Tags() } else {
) search3(criteria) search2(criteria)
else search2(criteria) }
} catch (ignored: ApiNotSupportedException) { } catch (ignored: ApiNotSupportedException) {
// Ensure backward compatibility with REST 1.3. // Ensure backward compatibility with REST 1.3.
searchOld(criteria) searchOld(criteria)
@ -262,28 +241,7 @@ open class RESTMusicService(
activeServerProvider.getActiveServer().name, name activeServerProvider.getActiveServer().name, name
) )
val fw = FileWriter(playlistFile) FileUtilKt.savePlaylist(playlistFile, playlist, name)
val bw = BufferedWriter(fw)
try {
fw.write("#EXTM3U\n")
for (e in playlist.getChildren()) {
var filePath = FileUtil.getSongFile(e).absolutePath
if (!File(filePath).exists()) {
val ext = FileUtil.getExtension(filePath)
val base = FileUtil.getBaseName(filePath)
filePath = "$base.complete.$ext"
}
fw.write(filePath + "\n")
}
} catch (e: IOException) {
Timber.w("Failed to save playlist: %s", name)
throw e
} finally {
bw.close()
fw.close()
}
} }
@Throws(Exception::class) @Throws(Exception::class)
@ -708,13 +666,7 @@ open class RESTMusicService(
// By registering a callback we ensure this info is saved in the database as well // By registering a callback we ensure this info is saved in the database as well
subsonicAPIClient.onProtocolChange = { subsonicAPIClient.onProtocolChange = {
Timber.i("Server minimum API version set to %s", it) Timber.i("Server minimum API version set to %s", it)
activeServerProvider.setMinimumApiVersion(it.toString()) activeServerProvider.setMinimumApiVersion(it.restApiVersion)
} }
} }
companion object {
private const val MUSIC_FOLDER_STORAGE_NAME = "music_folder"
private const val INDEXES_STORAGE_NAME = "indexes"
private const val ARTISTS_STORAGE_NAME = "artists"
}
} }

View File

@ -16,6 +16,7 @@ import java.util.concurrent.ExecutorService
import java.util.concurrent.Executors import java.util.concurrent.Executors
import org.moire.ultrasonic.api.subsonic.models.AlbumListType import org.moire.ultrasonic.api.subsonic.models.AlbumListType
import org.moire.ultrasonic.domain.Artist import org.moire.ultrasonic.domain.Artist
import org.moire.ultrasonic.domain.ArtistOrIndex
import org.moire.ultrasonic.domain.MusicDirectory import org.moire.ultrasonic.domain.MusicDirectory
import org.moire.ultrasonic.fragment.AlbumListModel import org.moire.ultrasonic.fragment.AlbumListModel
import org.moire.ultrasonic.fragment.ArtistListModel import org.moire.ultrasonic.fragment.ArtistListModel
@ -91,11 +92,11 @@ class AndroidAutoMediaBrowser(application: Application) {
class ArtistListObserver( class ArtistListObserver(
val idPrefix: String, val idPrefix: String,
val result: MediaBrowserServiceCompat.Result<List<MediaBrowserCompat.MediaItem>>, val result: MediaBrowserServiceCompat.Result<List<MediaBrowserCompat.MediaItem>>,
data: LiveData<List<Artist>> data: LiveData<List<ArtistOrIndex>>
) : ) :
Observer<List<Artist>> { Observer<List<ArtistOrIndex>> {
private var liveData: LiveData<List<Artist>>? = null private var liveData: LiveData<List<ArtistOrIndex>>? = null
init { init {
// Order is very important here. When observerForever is called onChanged // Order is very important here. When observerForever is called onChanged
@ -106,7 +107,7 @@ class AndroidAutoMediaBrowser(application: Application) {
liveData = data liveData = data
} }
override fun onChanged(artistList: List<Artist>?) { override fun onChanged(artistList: List<ArtistOrIndex>?) {
if (liveData == null) { if (liveData == null) {
// See comment in the initializer // See comment in the initializer
return return

View File

@ -0,0 +1,47 @@
/*
* FileUtil.kt
* Copyright (C) 2009-2021 Ultrasonic developers
*
* Distributed under terms of the GNU GPLv3 license.
*/
package org.moire.ultrasonic.util
import java.io.BufferedWriter
import java.io.File
import java.io.FileWriter
import java.io.IOException
import org.moire.ultrasonic.domain.MusicDirectory
import timber.log.Timber
// TODO: Convert FileUtil.java and merge into here.
object FileUtilKt {
fun savePlaylist(
playlistFile: File?,
playlist: MusicDirectory,
name: String
) {
val fw = FileWriter(playlistFile)
val bw = BufferedWriter(fw)
try {
fw.write("#EXTM3U\n")
for (e in playlist.getChildren()) {
var filePath = FileUtil.getSongFile(e).absolutePath
if (!File(filePath).exists()) {
val ext = FileUtil.getExtension(filePath)
val base = FileUtil.getBaseName(filePath)
filePath = "$base.complete.$ext"
}
fw.write(filePath + "\n")
}
} catch (e: IOException) {
Timber.w("Failed to save playlist: %s", name)
throw e
} finally {
bw.close()
fw.close()
}
}
}

View File

@ -1,5 +1,5 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources> <resources xmlns:tools="http://schemas.android.com/tools">
<string name="background_task.loading">Cargando&#8230;</string> <string name="background_task.loading">Cargando&#8230;</string>
<string name="background_task.network_error">Se ha producido un error de red. Por favor comprueba la dirección del servidor o reinténtalo mas tarde.</string> <string name="background_task.network_error">Se ha producido un error de red. Por favor comprueba la dirección del servidor o reinténtalo mas tarde.</string>
@ -111,7 +111,9 @@
<string name="main.songs_starred">Me gusta</string> <string name="main.songs_starred">Me gusta</string>
<string name="main.songs_title">Canciones</string> <string name="main.songs_title">Canciones</string>
<string name="main.videos">Vídeos</string> <string name="main.videos">Vídeos</string>
<string name="main.welcome_title">¡Saludos!</string> <string name="main.welcome_text_demo">Para utilizar Ultrasonic con tu música necesitas un <b>servidor propio</b>. \n\n➤ En caso de que quieras probar la aplicación primero, se puede añadir ahora un servidor de demostración. \n\n➤ En caso contrario, puedes configurar tu servidor personal en la <b>configuración</b>.</string>
<string name="main.welcome_title">¡Bienvenido a Ultrasonic!</string>
<string name="main.welcome_cancel">Llévame a la configuración</string>
<string name="menu.about">Acerca de</string> <string name="menu.about">Acerca de</string>
<string name="menu.common">Común</string> <string name="menu.common">Común</string>
<string name="menu.deleted_playlist">Eliminada lista de reproducción %s</string> <string name="menu.deleted_playlist">Eliminada lista de reproducción %s</string>
@ -319,7 +321,7 @@
<string name="settings.use_id3_summary">Usar el método de etiquetas ID3 en lugar del método basado en el sistema de ficheros</string> <string name="settings.use_id3_summary">Usar el método de etiquetas ID3 en lugar del método basado en el sistema de ficheros</string>
<string name="settings.show_artist_picture">Mostrar la imagen del artista en la lista de artistas</string> <string name="settings.show_artist_picture">Mostrar la imagen del artista en la lista de artistas</string>
<string name="settings.show_artist_picture_summary">Muestra la imagen del artista en la lista de artistas si está disponible</string> <string name="settings.show_artist_picture_summary">Muestra la imagen del artista en la lista de artistas si está disponible</string>
<string name="main.video">Vídeo</string> <string name="main.video" tools:ignore="UnusedResources">Vídeo</string>
<string name="settings.view_refresh">Refresco de la vista</string> <string name="settings.view_refresh">Refresco de la vista</string>
<string name="settings.view_refresh_500">.5 segundos</string> <string name="settings.view_refresh_500">.5 segundos</string>
<string name="settings.view_refresh_1000">1 segundo</string> <string name="settings.view_refresh_1000">1 segundo</string>
@ -339,7 +341,7 @@
<string name="util.bytes_format.gigabyte">0.00 GB</string> <string name="util.bytes_format.gigabyte">0.00 GB</string>
<string name="util.bytes_format.kilobyte">0 KB</string> <string name="util.bytes_format.kilobyte">0 KB</string>
<string name="util.bytes_format.megabyte">0.00 MB</string> <string name="util.bytes_format.megabyte">0.00 MB</string>
<string name="util.no_time">-:--</string> <string name="util.no_time" tools:ignore="TypographyDashes">-:--</string>
<string name="util.zero_time">0:00</string> <string name="util.zero_time">0:00</string>
<string name="widget.initial_text">Toca para seleccionar música</string> <string name="widget.initial_text">Toca para seleccionar música</string>
<string name="widget.sdcard_busy">Tarjeta SD no disponible</string> <string name="widget.sdcard_busy">Tarjeta SD no disponible</string>
@ -434,6 +436,7 @@
<string name="server_editor.authentication">Autenticación</string> <string name="server_editor.authentication">Autenticación</string>
<string name="server_editor.advanced">Configuración avanzada</string> <string name="server_editor.advanced">Configuración avanzada</string>
<string name="server_editor.disabled_feature">Una o más funciones se han deshabilitado porque el servidor no las admite.\nPuedes ejecutar esta prueba nuevamente en cualquier momento.</string> <string name="server_editor.disabled_feature">Una o más funciones se han deshabilitado porque el servidor no las admite.\nPuedes ejecutar esta prueba nuevamente en cualquier momento.</string>
<string name="server_menu.demo">Servidor de demostración</string>
<plurals name="select_album_n_songs"> <plurals name="select_album_n_songs">
<item quantity="one">1 canción</item> <item quantity="one">1 canción</item>

View File

@ -1,5 +1,5 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources> <resources xmlns:tools="http://schemas.android.com/tools">
<string name="background_task.loading">Carregando&#8230;</string> <string name="background_task.loading">Carregando&#8230;</string>
<string name="background_task.network_error">Ocorreu um erro de rede. Verifique o endereço do servidor ou tente mais tarde.</string> <string name="background_task.network_error">Ocorreu um erro de rede. Verifique o endereço do servidor ou tente mais tarde.</string>
@ -15,9 +15,16 @@
<string name="button_bar.chat">Chat</string> <string name="button_bar.chat">Chat</string>
<string name="button_bar.home">Menu Principal</string> <string name="button_bar.home">Menu Principal</string>
<string name="button_bar.now_playing">Tocando Agora</string> <string name="button_bar.now_playing">Tocando Agora</string>
<string name="buttons.play">Tocar</string>
<string name="buttons.pause">Pausar</string>
<string name="buttons.repeat">Repetir</string>
<string name="buttons.shuffle">Misturar</string>
<string name="buttons.stop">Parar</string>
<string name="buttons.next">Próxima</string>
<string name="buttons.previous">Anterior</string>
<string name="podcasts.label">Podcasts</string> <string name="podcasts.label">Podcasts</string>
<string name="podcasts_channels.empty">Nenhum canal de podcasts registrado</string> <string name="podcasts_channels.empty">Nenhum canal de podcasts registrado</string>
<string name="button_bar.podcasts">Podcast</string> <string name="button_bar.podcasts">Podcasts</string>
<string name="button_bar.playlists">Playlists</string> <string name="button_bar.playlists">Playlists</string>
<string name="button_bar.search">Pesquisa</string> <string name="button_bar.search">Pesquisa</string>
<string name="chat.send_a_message">Enviar uma mensagem</string> <string name="chat.send_a_message">Enviar uma mensagem</string>
@ -32,8 +39,11 @@
<string name="common.name">Nome</string> <string name="common.name">Nome</string>
<string name="common.ok">OK</string> <string name="common.ok">OK</string>
<string name="common.pin">Fixar</string> <string name="common.pin">Fixar</string>
<string name="common.pause">Pausar</string>
<string name="common.play">Tocar</string>
<string name="common.play_last">Tocar por Último</string> <string name="common.play_last">Tocar por Último</string>
<string name="common.play_next">Tocar na Próxima</string> <string name="common.play_next">Tocar na Próxima</string>
<string name="common.play_previous">Tocar a Anterior</string>
<string name="common.play_now">Tocar Agora</string> <string name="common.play_now">Tocar Agora</string>
<string name="common.play_shuffled">Tocar Aleatoriamente</string> <string name="common.play_shuffled">Tocar Aleatoriamente</string>
<string name="common.public">Público</string> <string name="common.public">Público</string>
@ -101,7 +111,9 @@
<string name="main.songs_starred">Favoritas</string> <string name="main.songs_starred">Favoritas</string>
<string name="main.songs_title">Músicas</string> <string name="main.songs_title">Músicas</string>
<string name="main.videos">Vídeos</string> <string name="main.videos">Vídeos</string>
<string name="main.welcome_title">Bem-vindo!</string> <string name="main.welcome_text_demo">Para usar o Ultrasonic com sua própria música, você precisará de um <b>servidor próprio</b>.\n\n➤ Caso queira experimentar o aplicativo primeiro, você pode adicionar um servidor de demonstração agora.\n\n➤ Caso contrário, configure seu servidor nas <b>configurações</b>.</string>
<string name="main.welcome_title">Bem-vindo ao Ultrasonic!</string>
<string name="main.welcome_cancel">Vá para as configurações</string>
<string name="menu.about">Sobre</string> <string name="menu.about">Sobre</string>
<string name="menu.common">Comum</string> <string name="menu.common">Comum</string>
<string name="menu.deleted_playlist">Playlist excluída %s</string> <string name="menu.deleted_playlist">Playlist excluída %s</string>
@ -250,6 +262,8 @@
<string name="settings.playback.resume_play_on_headphones_plug.summary">O aplicativo retomará a reprodução em pausa na inserção dos fones de ouvido no dispositivo.</string> <string name="settings.playback.resume_play_on_headphones_plug.summary">O aplicativo retomará a reprodução em pausa na inserção dos fones de ouvido no dispositivo.</string>
<string name="settings.screen_lit_summary">Manter a tela ligada enquanto baixando aumenta a velocidade de download.</string> <string name="settings.screen_lit_summary">Manter a tela ligada enquanto baixando aumenta a velocidade de download.</string>
<string name="settings.screen_lit_title">Manter a Tela Ligada</string> <string name="settings.screen_lit_title">Manter a Tela Ligada</string>
<string name="settings.scrobble_summary">Lembrar de configurar o usuário e senha no(s) serviço(s) Scrobble do servidor</string>
<string name="settings.scrobble_title">Scrobble minhas músicas</string>
<string name="settings.search_1">1</string> <string name="settings.search_1">1</string>
<string name="settings.search_10">10</string> <string name="settings.search_10">10</string>
<string name="settings.search_100">100</string> <string name="settings.search_100">100</string>
@ -307,7 +321,7 @@
<string name="settings.use_id3_summary">Usar as etiquetas ID3 ao invés do sistema de arquivos</string> <string name="settings.use_id3_summary">Usar as etiquetas ID3 ao invés do sistema de arquivos</string>
<string name="settings.show_artist_picture">Mostrar Foto do Artista na Lista</string> <string name="settings.show_artist_picture">Mostrar Foto do Artista na Lista</string>
<string name="settings.show_artist_picture_summary">Mostrar a imagem do artista na lista de artistas, se disponível</string> <string name="settings.show_artist_picture_summary">Mostrar a imagem do artista na lista de artistas, se disponível</string>
<string name="main.video">Vídeo</string> <string name="main.video" tools:ignore="UnusedResources">Vídeo</string>
<string name="settings.view_refresh">Atualização da Tela</string> <string name="settings.view_refresh">Atualização da Tela</string>
<string name="settings.view_refresh_500">.5 segundos</string> <string name="settings.view_refresh_500">.5 segundos</string>
<string name="settings.view_refresh_1000">1 segundo</string> <string name="settings.view_refresh_1000">1 segundo</string>
@ -327,7 +341,7 @@
<string name="util.bytes_format.gigabyte">0.00 GB</string> <string name="util.bytes_format.gigabyte">0.00 GB</string>
<string name="util.bytes_format.kilobyte">0 KB</string> <string name="util.bytes_format.kilobyte">0 KB</string>
<string name="util.bytes_format.megabyte">0.00 MB</string> <string name="util.bytes_format.megabyte">0.00 MB</string>
<string name="util.no_time">-:--</string> <string name="util.no_time" tools:ignore="TypographyDashes">-:--</string>
<string name="util.zero_time">0:00</string> <string name="util.zero_time">0:00</string>
<string name="widget.initial_text">Toque para selecionar a música</string> <string name="widget.initial_text">Toque para selecionar a música</string>
<string name="widget.sdcard_busy">Cartão SD indisponível</string> <string name="widget.sdcard_busy">Cartão SD indisponível</string>
@ -421,6 +435,9 @@
<string name="server_menu.move_down">Para baixo</string> <string name="server_menu.move_down">Para baixo</string>
<string name="server_editor.authentication">Autenticação</string> <string name="server_editor.authentication">Autenticação</string>
<string name="server_editor.advanced">Configurações avançadas</string> <string name="server_editor.advanced">Configurações avançadas</string>
<string name="server_editor.disabled_feature">Um ou mais recursos foram desativados porque o servidor não os suporta.\nVocê pode rodar este teste novamente a qualquer momento.</string>
<string name="server_menu.demo">Servidor Demonstração</string>
<plurals name="select_album_n_songs"> <plurals name="select_album_n_songs">
<item quantity="one">%d música</item> <item quantity="one">%d música</item>
<item quantity="other">%d músicas</item> <item quantity="other">%d músicas</item>

View File

@ -1,5 +1,5 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources> <resources xmlns:tools="http://schemas.android.com/tools">
<string name="background_task.loading">載入中…</string> <string name="background_task.loading">載入中…</string>
<string name="button_bar.bookmarks">書籤</string> <string name="button_bar.bookmarks">書籤</string>

View File

@ -11,7 +11,7 @@ import org.moire.ultrasonic.api.subsonic.models.Indexes
/** /**
* Unit tests for extension functions in [APIIndexesConverter.kt]. * Unit tests for extension functions in [APIIndexesConverter.kt].
*/ */
class APIIndexesConverterTest { class APIIndexConverterTest {
@Test @Test
fun `Should convert Indexes entity`() { fun `Should convert Indexes entity`() {
val artistsA = listOf( val artistsA = listOf(
@ -31,15 +31,12 @@ class APIIndexesConverterTest {
shortcutList = artistsA shortcutList = artistsA
) )
val convertedEntity = entity.toDomainEntity() val convertedEntity = entity.toArtistList()
val expectedArtists = (artistsA + artistsT).map { it.toDomainEntity() }.toMutableList() val expectedArtists = (artistsA + artistsT).map { it.toDomainEntity() }.toMutableList()
with(convertedEntity) { with(convertedEntity) {
lastModified `should be equal to` entity.lastModified size `should be equal to` expectedArtists.size
ignoredArticles `should be equal to` entity.ignoredArticles this `should be equal to` expectedArtists
artists.size `should be equal to` expectedArtists.size
artists `should be equal to` expectedArtists
shortcuts `should be equal to` artistsA.map { it.toDomainEntity() }.toMutableList()
} }
} }
} }