Merge branch 'bugfixes' into 'develop'

Bugfixes

See merge request ultrasonic/ultrasonic!1447
This commit is contained in:
birdbird committed 2026-10-07 07:52:55 +00:00
commit 17b86b218c
19 files changed
+471 -152

No files matched your search

@@ -7,10 +7,10 @@
package org.moire.ultrasonic.data
import androidx.room.Room
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
@@ -40,8 +40,7 @@ import timber.log.Timber
class ActiveServerProvider(private val repository: ServerSettingDao) :
CoroutineScope by CoroutinePatterns.loggingScope(Dispatchers.IO) {
private var cachedDatabase: MetaDatabase? = null
private var cachedServerId: Int? = null
private val metadataDatabases = MetaDatabaseRegistry()
private val _accessState = MutableStateFlow(
LibraryAccessState(Settings.activeServer, Settings.explicitOffline)
@@ -111,6 +110,8 @@ class ActiveServerProvider(private val repository: ServerSettingDao) :
// The server selected by the user; this is not the effective server while offline.
fun getSelectedServerId(): Int = Settings.activeServer
suspend fun getServerById(serverId: Int): ServerSetting? = repository.findById(serverId)
/**
* Queries if Scrobbling is enabled
*/
@@ -281,17 +282,15 @@ class ActiveServerProvider(private val repository: ServerSettingDao) :
}
/** Clears a deleted selected server; unlike manual offline mode, there is nothing to restore. */
fun clearSelectedServer() {
launch {
withContext(Dispatchers.Main) {
val state = _accessState.value
if (state.selectedServerId == OFFLINE_DB_ID && state.explicitOffline) {
return@withContext
}
Settings.activeServer = OFFLINE_DB_ID
Settings.explicitOffline = true
publishResolvedAccessState(OFFLINE_DB_ID, explicitOffline = true, null)
fun clearSelectedServer(): Job = launch {
withContext(Dispatchers.Main) {
val state = _accessState.value
if (state.selectedServerId == OFFLINE_DB_ID && state.explicitOffline) {
return@withContext
}
Settings.activeServer = OFFLINE_DB_ID
Settings.explicitOffline = true
publishResolvedAccessState(OFFLINE_DB_ID, explicitOffline = true, null)
}
}
@@ -299,66 +298,33 @@ class ActiveServerProvider(private val repository: ServerSettingDao) :
fun getActiveMetaDatabase(): MetaDatabase {
if (isOffline()) return offlineMetaDatabase
return getSelectedMetaDatabase()
return getMetaDatabaseForServer(getSelectedServerId())
}
/**
* Metadata for the server selected by the user, even if it is temporarily unreachable.
* Background work that belongs to that server (for example a completed download) must use
* this rather than the effective, offline database.
*/
/** Metadata storage owned by a server, independent of the current UI selection. */
@Synchronized
fun getSelectedMetaDatabase(): MetaDatabase {
val selectedServer = getSelectedServerId()
fun getMetaDatabaseForServer(serverId: Int): MetaDatabase =
metadataDatabases.forServer(serverId)
if (selectedServer == cachedServerId && cachedDatabase != null) {
return cachedDatabase!!
}
if (selectedServer < 1) {
return offlineMetaDatabase
}
Timber.i("Switching to new database, id:$selectedServer")
cachedServerId = selectedServer
cachedDatabase = initDatabase(selectedServer)
return cachedDatabase!!
}
val offlineMetaDatabase: MetaDatabase by lazy {
initDatabase(0)
}
private fun initDatabase(serverId: Int): MetaDatabase = Room.databaseBuilder(
UApp.applicationContext(),
MetaDatabase::class.java,
METADATA_DB + serverId
)
.addMigrations(META_MIGRATION_2_3, META_MIGRATION_3_4)
.fallbackToDestructiveMigrationOnDowngrade(true)
.build()
val offlineMetaDatabase: MetaDatabase get() = metadataDatabases.forServer(0)
@Synchronized
fun deleteMetaDatabase(id: Int) {
if (cachedServerId == id) {
cachedDatabase?.close()
cachedDatabase = null
cachedServerId = null
}
UApp.applicationContext().deleteDatabase(METADATA_DB + id)
metadataDatabases.delete(id)
Timber.i("Deleted metadataBase, id:$id")
}
/**
* Sets the minimum Subsonic API version of the current server.
*/
fun setMinimumApiVersion(apiVersion: String) {
fun setMinimumApiVersionForServer(serverId: Int, apiVersion: String) {
launch {
val server = _accessState.value.server ?: return@launch
val server = repository.findById(serverId) ?: return@launch
val updated = server.copy(minimumApiVersion = apiVersion)
repository.update(updated)
_accessState.value = _accessState.value.copy(server = updated)
if (_accessState.value.selectedServerId == serverId && !_accessState.value.isOffline) {
_accessState.value = _accessState.value.copy(server = updated)
}
}
}
@@ -19,7 +19,6 @@ import androidx.media3.datasource.HttpDataSource.HttpDataSourceException
import java.io.IOException
import java.io.InputStream
import java.io.InterruptedIOException
import org.moire.ultrasonic.util.AbstractFile
import org.moire.ultrasonic.util.FileUtil
import org.moire.ultrasonic.util.Storage
import timber.log.Timber
@@ -45,9 +44,9 @@ class CachedDataSource(private var upstreamDataSource: DataSource) : BaseDataSou
private var responseByteStream: InputStream? = null
private var openedFile = false
private var cachePath: String? = null
private var cacheFile: AbstractFile? = null
override fun open(dataSpec: DataSpec): Long {
if (this.dataSpec != null) close()
Timber.i(
"CachedDatasource: Open: %s",
dataSpec.toString()
@@ -56,6 +55,9 @@ class CachedDataSource(private var upstreamDataSource: DataSource) : BaseDataSou
this.dataSpec = dataSpec
bytesRead = 0
bytesToRead = 0
cachePath = null
responseByteStream = null
openedFile = false
val components = dataSpec.uri.toString().split('|')
val path = components[2]
@@ -171,13 +173,20 @@ class CachedDataSource(private var upstreamDataSource: DataSource) : BaseDataSou
override fun close() {
Timber.i("CachedDatasource: close %s", openedFile)
if (openedFile) {
openedFile = false
transferEnded()
responseByteStream?.close()
try {
if (openedFile) {
openedFile = false
transferEnded()
responseByteStream?.close()
} else if (dataSpec != null) {
upstreamDataSource.close()
}
} finally {
responseByteStream = null
} else {
upstreamDataSource.close()
cachePath = null
dataSpec = null
bytesRead = 0
bytesToRead = 0
}
}
@@ -195,16 +204,14 @@ class CachedDataSource(private var upstreamDataSource: DataSource) : BaseDataSou
if (!found) return -1
val file = Storage.getFromPath(filePath) ?: return -1
val length = file.getDocumentFileDescriptor("r")?.use { it.length } ?: return -1
if (length <= 0) return -1
responseByteStream = file.getFileInputStream()
cachePath = filePath
openedFile = true
cacheFile = Storage.getFromPath(filePath)!!
responseByteStream = cacheFile!!.getFileInputStream()
val descriptor = cacheFile!!.getDocumentFileDescriptor("r")
val length = descriptor!!.length
descriptor.close()
return length
}
}
@@ -0,0 +1,39 @@
package org.moire.ultrasonic.data
import androidx.room.Room
import org.moire.ultrasonic.app.UApp
import timber.log.Timber
/** Owns one Room handle per server for the lifetime of the application process. */
internal class MetaDatabaseRegistry(
private val open: (Int) -> MetaDatabase = { serverId ->
Room.databaseBuilder(
UApp.applicationContext(),
MetaDatabase::class.java,
ActiveServerProvider.METADATA_DB + serverId
)
.addMigrations(META_MIGRATION_2_3, META_MIGRATION_3_4)
.fallbackToDestructiveMigrationOnDowngrade(true)
.build()
},
private val deleteFile: (Int) -> Unit = { serverId ->
UApp.applicationContext().deleteDatabase(ActiveServerProvider.METADATA_DB + serverId)
}
) {
private val databases = mutableMapOf<Int, MetaDatabase>()
@Synchronized
fun forServer(serverId: Int): MetaDatabase {
val databaseId = serverId.coerceAtLeast(0)
return databases.getOrPut(databaseId) {
Timber.i("Opening metadata database, id:$databaseId")
open(databaseId)
}
}
@Synchronized
fun delete(serverId: Int) {
databases.remove(serverId)?.close()
deleteFile(serverId)
}
}
@@ -61,7 +61,7 @@ val appPermanentStorage = module {
single { get<AppDatabase>().serverSettingDao() }
viewModel { ServerSettingsModel(get(), get(), get()) }
viewModel { ServerSettingsModel(get(), get(), get(), get()) }
viewModel { EditServerModel(get()) }
viewModel { ChatViewModel() }
@@ -36,12 +36,9 @@ import kotlinx.coroutines.launch
import org.koin.android.ext.android.inject
import org.koin.androidx.viewmodel.ext.android.viewModel
import org.moire.ultrasonic.R
import org.moire.ultrasonic.data.ActiveServerProvider
import org.moire.ultrasonic.data.ServerSetting
import org.moire.ultrasonic.model.EditServerModel
import org.moire.ultrasonic.model.ServerSettingsModel
import org.moire.ultrasonic.service.MusicServiceFactory
import org.moire.ultrasonic.service.UltrasonicBus
import org.moire.ultrasonic.util.CommunicationError
import org.moire.ultrasonic.util.ErrorDialog
import org.moire.ultrasonic.util.InfoDialog
@@ -57,8 +54,6 @@ private const val DIALOG_PADDING = 12
class EditServerFragment : Fragment() {
private val serverSettingsModel: ServerSettingsModel by viewModel()
private val activeServerProvider: ActiveServerProvider by inject()
private val musicServiceFactory: MusicServiceFactory by inject()
private val communicationError: CommunicationError by inject()
private var currentServerSetting: ServerSetting? = null
@@ -100,6 +95,7 @@ class EditServerFragment : Fragment() {
savedInstanceState: Bundle?
): View? = inflater.inflate(R.layout.server_edit, container, false)
@Suppress("LongMethod", "TooGenericExceptionCaught")
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
@@ -124,32 +120,27 @@ class EditServerFragment : Fragment() {
if (t != null) {
currentServerSetting = t
if (!isInstanceStateSaved) setFields()
// Remove the minimum API version so it can be detected again
if (currentServerSetting?.minimumApiVersion != null) {
currentServerSetting!!.minimumApiVersion = null
serverSettingsModel.updateItem(currentServerSetting)
if (
activeServerProvider.getActiveServer().id ==
currentServerSetting!!.id
) {
musicServiceFactory.resetMusicService()
}
}
}
}
saveButton!!.setOnClickListener {
if (currentServerSetting != null) {
if (getFields()) {
serverSettingsModel.updateItem(currentServerSetting)
// Apply modifications if the current server was modified
if (
activeServerProvider.getActiveServer().id ==
currentServerSetting!!.id
) {
musicServiceFactory.resetMusicService()
UltrasonicBus.publishActiveServerChanged(currentServerSetting!!)
val updated = currentServerSetting!!.copy(minimumApiVersion = null)
saveButton!!.isEnabled = false
viewLifecycleOwner.lifecycleScope.launch {
try {
serverSettingsModel.updateItem(updated)
findNavController().navigateUp()
} catch (cancelled: CancellationException) {
throw cancelled
} catch (exception: Exception) {
saveButton?.isEnabled = true
ErrorDialog.Builder(requireContext())
.setTitle(R.string.error_label)
.setMessage(communicationError.getErrorMessage(exception))
.show()
}
}
findNavController().navigateUp()
}
}
}
@@ -7,8 +7,12 @@ import android.view.ViewGroup
import android.widget.AdapterView
import android.widget.ListView
import androidx.fragment.app.Fragment
import androidx.lifecycle.lifecycleScope
import androidx.navigation.fragment.findNavController
import com.google.android.material.floatingactionbutton.FloatingActionButton
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.koin.android.ext.android.inject
import org.koin.androidx.viewmodel.ext.android.viewModel
import org.moire.ultrasonic.R
@@ -16,6 +20,7 @@ import org.moire.ultrasonic.adapters.ServerRowAdapter
import org.moire.ultrasonic.data.ActiveServerProvider
import org.moire.ultrasonic.data.ServerSetting
import org.moire.ultrasonic.model.ServerSettingsModel
import org.moire.ultrasonic.service.DownloadManager
import org.moire.ultrasonic.util.ErrorDialog
import org.moire.ultrasonic.util.UiUtil
import timber.log.Timber
@@ -28,6 +33,7 @@ class ServerSelectorFragment : Fragment() {
private var listView: ListView? = null
private val serverSettingsModel: ServerSettingsModel by viewModel()
private val activeServerProvider: ActiveServerProvider by inject()
private val downloadManager: DownloadManager by inject()
private var serverRowAdapter: ServerRowAdapter? = null
@Override
@@ -96,14 +102,15 @@ class ServerSelectorFragment : Fragment() {
val activeServerId = activeServerProvider.getSelectedServerId()
// If the currently active server is deleted, go offline
if (id == activeServerId) activeServerProvider.clearSelectedServer()
serverSettingsModel.deleteItemById(id)
// Clear the metadata cache
activeServerProvider.deleteMetaDatabase(id)
Timber.i("Server deleted, id: $id")
lifecycleScope.launch {
if (id == activeServerId) activeServerProvider.clearSelectedServer().join()
downloadManager.cancelForServer(id)
serverSettingsModel.deleteItemById(id)
withContext(Dispatchers.IO) {
activeServerProvider.deleteMetaDatabase(id)
}
Timber.i("Server deleted, id: $id")
}
}
.setNegativeButton(R.string.common_cancel) { dialog, _ ->
dialog.dismiss()
@@ -15,6 +15,8 @@ import org.moire.ultrasonic.data.ActiveServerProvider
import org.moire.ultrasonic.data.ActiveServerProvider.Companion.OFFLINE_DB_ID
import org.moire.ultrasonic.data.ServerSetting
import org.moire.ultrasonic.data.ServerSettingDao
import org.moire.ultrasonic.service.MusicServiceFactory
import org.moire.ultrasonic.service.UltrasonicBus
import timber.log.Timber
/**
@@ -23,6 +25,7 @@ import timber.log.Timber
class ServerSettingsModel(
private val repository: ServerSettingDao,
private val activeServerProvider: ActiveServerProvider,
private val musicServiceFactory: MusicServiceFactory,
application: Application
) : AndroidViewModel(application) {
@@ -94,31 +97,33 @@ class ServerSettingsModel(
/**
* Removes a Setting from the database
*/
fun deleteItemById(id: Int) {
suspend fun deleteItemById(id: Int) {
if (id == OFFLINE_DB_ID) return
viewModelScope.launch {
val itemToBeDeleted = repository.findById(id)
if (itemToBeDeleted != null) {
repository.delete(itemToBeDeleted)
Timber.d("deleteItem deleted id: $id")
reindexSettings()
activeServerProvider.invalidateCache()
}
val itemToBeDeleted = repository.findById(id)
if (itemToBeDeleted != null) {
repository.delete(itemToBeDeleted)
Timber.d("deleteItem deleted id: $id")
reindexSettings()
activeServerProvider.invalidateCache()
}
}
/**
* Updates a Setting in the database
*/
fun updateItem(serverSetting: ServerSetting?) {
suspend fun updateItem(serverSetting: ServerSetting?) {
if (serverSetting == null) return
appScope.launch {
repository.update(serverSetting)
activeServerProvider.invalidateCache()
Timber.d("updateItem updated server setting: $serverSetting")
repository.update(serverSetting)
activeServerProvider.invalidateCache()
if (activeServerProvider.getSelectedServerId() == serverSetting.id) {
musicServiceFactory.resetMusicService()
if (!activeServerProvider.isOffline()) {
UltrasonicBus.publishActiveServerChanged(serverSetting)
}
}
Timber.d("updateItem updated server setting: $serverSetting")
}
/**
@@ -234,16 +234,20 @@ class DownloadManager(
// Remove tracks which are currently downloading
filteredTracks = filteredTracks.filter {
!downloadQueue.any { i -> i.id == it.id } && !activeDownloads.containsKey(it.id)
!downloadQueue.any { i -> i.id == it.id } &&
!activeDownloads.containsKey(it.id)
}
// The remaining tracks should be added to the download queue
// By using the counter we ensure that the songs are added in the correct order
var priority = 0
val selectedServerId = activeServerProvider.getSelectedServerId()
val tracksToDownload =
filteredTracks.map {
val sourceServerId = it.downloadSourceServerId(selectedServerId)
DownloadableTrack(
it,
it.copy(serverId = sourceServerId),
sourceServerId,
save,
0,
if (isHighPriority) priority++ else backgroundPriorityCounter++
@@ -356,6 +360,17 @@ class DownloadManager(
failedList.clear()
}
/** Finishes work for a server before its metadata database is closed and deleted. */
suspend fun cancelForServer(serverId: Int) {
val tasks = synchronized(this) {
downloadQueue.removeIf { it.sourceServerId == serverId }
failedList.entries.removeIf { it.value.sourceServerId == serverId }
activeDownloads.values.filter { it.downloadTrack.sourceServerId == serverId }
}
tasks.forEach { it.cancelAndJoin() }
updateLiveData()
}
private fun delete(track: Track) {
CoroutineScope(Dispatchers.IO).launch {
deleteAsync(track)
@@ -14,6 +14,7 @@ import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineExceptionHandler
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import org.moire.ultrasonic.data.ActiveServerProvider
@@ -41,11 +42,12 @@ class DownloadTask(
val downloadTrack: DownloadableTrack,
private val scope: CoroutineScope,
private val stateChangedCallback: (DownloadableTrack, DownloadState, progress: Int?) -> Unit,
musicServiceFactory: MusicServiceFactory,
private val musicServiceFactory: MusicServiceFactory,
private val imageLoaderProvider: ImageLoaderProvider,
private val activeServerProvider: ActiveServerProvider
) {
private val musicService = musicServiceFactory.getMusicService()
private lateinit var serverService: MusicServiceFactory.ServerService
private val musicService: MusicService get() = serverService.musicService
private var job: Job? = null
private var inputStream: InputStream? = null
@@ -219,6 +221,7 @@ class DownloadTask(
fun start() {
Timber.i("Launching new Job ${downloadTrack.pinnedFile}")
job = scope.launch(exceptionHandler()) {
serverService = musicServiceFactory.forServer(downloadTrack.sourceServerId)
FileUtil.createDirectoryForParent(downloadTrack.pinnedFile)
if (!checkIfExists() && isActive) {
download()
@@ -233,8 +236,14 @@ class DownloadTask(
job?.cancel()
}
suspend fun cancelAndJoin() {
job?.cancelAndJoin()
}
private fun Track.cacheMetadataAndArtwork() {
val onlineDB = activeServerProvider.getSelectedMetaDatabase()
val onlineDB = activeServerProvider.getMetaDatabaseForServer(
downloadTrack.sourceServerId
)
val offlineDB = activeServerProvider.offlineMetaDatabase
var artistId: String? = if (artistId.isNullOrEmpty()) null else artistId
@@ -280,7 +289,7 @@ class DownloadTask(
// Now cache the track data
offlineDB.trackDao().insert(this)
// Download the largest size that we can display in the UI
imageLoaderProvider.executeOn { imageLoader ->
imageLoaderProvider.executeOn(serverService.apiClient) { imageLoader ->
imageLoader.downloadCoverArt(this@cacheMetadataAndArtwork)
// Cache small copies of the Artist picture
directArtist?.let { imageLoader.cacheArtistPicture(it) }
@@ -296,8 +305,7 @@ class DownloadTask(
}
activeServerProvider.offlineMetaDatabase.localMediaCatalogDao().upsert(
toLocalMediaCatalogEntry(
sourceServerId = serverId.takeIf { it > 0 }
?: activeServerProvider.getSelectedServerId(),
sourceServerId = downloadTrack.sourceServerId,
localPath = localPath,
pinned = localPath == getPinnedFile()
)
@@ -15,6 +15,7 @@ import org.moire.ultrasonic.util.FileUtil.getPinnedFile
class DownloadableTrack(
val track: Track,
val sourceServerId: Int,
var pinned: Boolean,
var tryCount: Int,
var priority: Int
@@ -28,3 +29,6 @@ class DownloadableTrack(
override fun compareTo(other: Identifiable) = compareTo(other as DownloadableTrack)
fun compareTo(other: DownloadableTrack): Int = priority.compareTo(other.priority)
}
fun Track.downloadSourceServerId(selectedServerId: Int): Int =
serverId.takeIf { it > 0 } ?: selectedServerId
@@ -25,6 +25,7 @@ import org.moire.ultrasonic.api.subsonic.SubsonicAPIVersions
import org.moire.ultrasonic.api.subsonic.SubsonicClientConfiguration
import org.moire.ultrasonic.data.ActiveServerProvider
import org.moire.ultrasonic.data.LibraryAccessState
import org.moire.ultrasonic.data.ServerSetting
import org.moire.ultrasonic.util.Constants
import timber.log.Timber
@@ -55,6 +56,17 @@ class MusicServiceFactory(
}
) {
data class ServerService(val musicService: MusicService, val apiClient: SubsonicAPIClient)
/** Builds a service whose configuration and entity IDs remain tied to one server. */
suspend fun forServer(serverId: Int): ServerService {
val server = requireNotNull(activeServerProvider.getServerById(serverId)) {
"Server $serverId was deleted before its download started"
}.copy()
val client = SubsonicAPIClient(configurationFor(server), okLogger)
return ServerService(RESTMusicService(client, activeServerProvider, server), client)
}
// Identifies the access state the delegate was last built for. Only the transition
// dimensions are compared: server property edits intentionally do not rebuild.
private var loadedStateKey: Triple<Int, Boolean, LibraryAccessState.AutomaticOfflineReason?>? =
@@ -107,20 +119,7 @@ class MusicServiceFactory(
private fun buildMusicService() {
Timber.i("Building the MusicService for the current access state")
val server = activeServerProvider.getActiveServer()
val configuration = SubsonicClientConfiguration(
baseUrl = server.url,
username = server.userName,
password = server.password,
minimalProtocolVersion = SubsonicAPIVersions.getClosestKnownClientApiVersion(
server.minimumApiVersion ?: Constants.REST_PROTOCOL_VERSION
),
clientID = Constants.REST_CLIENT_ID,
allowSelfSignedCertificate = server.allowSelfSignedCertificate,
forcePlainTextPassword = server.forcePlainTextPassword,
debug = BuildConfig.DEBUG,
isRealProtocolVersion = server.minimumApiVersion != null
)
val apiClient = SubsonicAPIClient(configuration, okLogger)
val apiClient = SubsonicAPIClient(configurationFor(server), okLogger)
currentApiClient = apiClient
currentDelegate = if (activeServerProvider.isOffline()) {
offlineServiceFactory()
@@ -130,6 +129,20 @@ class MusicServiceFactory(
loadedStateKey = accessStateKey
}
private fun configurationFor(server: ServerSetting) = SubsonicClientConfiguration(
baseUrl = server.url,
username = server.userName,
password = server.password,
minimalProtocolVersion = SubsonicAPIVersions.getClosestKnownClientApiVersion(
server.minimumApiVersion ?: Constants.REST_PROTOCOL_VERSION
),
clientID = Constants.REST_CLIENT_ID,
allowSelfSignedCertificate = server.allowSelfSignedCertificate,
forcePlainTextPassword = server.forcePlainTextPassword,
debug = BuildConfig.DEBUG,
isRealProtocolVersion = server.minimumApiVersion != null
)
private val accessStateKey: Triple<Int, Boolean, LibraryAccessState.AutomaticOfflineReason?>
get() {
val state = activeServerProvider.getLibraryAccessState()
@@ -18,6 +18,7 @@ import org.moire.ultrasonic.api.subsonic.models.JukeboxAction
import org.moire.ultrasonic.api.subsonic.throwOnFailure
import org.moire.ultrasonic.api.subsonic.toStreamResponse
import org.moire.ultrasonic.data.ActiveServerProvider
import org.moire.ultrasonic.data.ServerSetting
import org.moire.ultrasonic.domain.Album
import org.moire.ultrasonic.domain.Artist
import org.moire.ultrasonic.domain.Bookmark
@@ -43,6 +44,7 @@ import org.moire.ultrasonic.domain.toIndexList
import org.moire.ultrasonic.domain.toMusicDirectoryDomainEntity
import org.moire.ultrasonic.domain.toTrackEntity
import org.moire.ultrasonic.util.FileUtil
import org.moire.ultrasonic.util.Settings
import timber.log.Timber
/**
@@ -51,9 +53,12 @@ import timber.log.Timber
@Suppress("LargeClass")
open class RESTMusicService(
private val subsonicAPIClient: SubsonicAPIClient,
private val activeServerProvider: ActiveServerProvider
private val activeServerProvider: ActiveServerProvider,
private val boundServer: ServerSetting? = null
) : MusicService {
private val server: ServerSetting get() = boundServer ?: activeServerProvider.getActiveServer()
// Shortcut to the API
@Suppress("VariableNaming", "PropertyName")
val API = subsonicAPIClient.api
@@ -85,7 +90,7 @@ open class RESTMusicService(
val response = API.getIndexes(musicFolderId, null).execute().throwOnFailure()
return response.body()!!.indexes.toIndexList(
activeServerProvider.getSelectedServerId(),
activeServerId,
musicFolderId
)
}
@@ -142,7 +147,9 @@ open class RESTMusicService(
@Throws(Exception::class)
override fun search(criteria: SearchCriteria): SearchResult = try {
if (activeServerProvider.shouldUseId3Tags()) {
if (boundServer?.let { Settings.id3TagsEnabledOnline }
?: activeServerProvider.shouldUseId3Tags()
) {
search3(criteria)
} else {
search2(criteria)
@@ -212,7 +219,7 @@ open class RESTMusicService(
@Throws(IOException::class)
private fun savePlaylist(name: String, playlist: MusicDirectory) {
val playlistFile = FileUtil.getPlaylistFile(
activeServerProvider.getActiveServer().name,
server.name,
name
)
@@ -443,7 +450,7 @@ open class RESTMusicService(
}
override fun isJukeboxAvailable(): Boolean {
val username = activeServerProvider.getActiveServer().userName
val username = server.userName
return getUser(username).jukeboxRole
}
@@ -596,14 +603,14 @@ open class RESTMusicService(
}
private val activeServerId: Int
get() = activeServerProvider.getSelectedServerId()
get() = boundServer?.id ?: activeServerProvider.getSelectedServerId()
init {
// The client will notice if the minimum supported API version has changed
// By registering a callback we ensure this info is saved in the database as well
subsonicAPIClient.onProtocolChange = {
Timber.i("Server minimum API version set to %s", it)
activeServerProvider.setMinimumApiVersion(it.restApiVersion)
activeServerProvider.setMinimumApiVersionForServer(activeServerId, it.restApiVersion)
}
}
}
@@ -67,6 +67,14 @@ class ImageLoaderProvider(
}
}
/** Runs download artwork work with the API client captured by its server task. */
fun executeOn(client: SubsonicAPIClient, cb: (iL: ImageLoader) -> Unit) {
launch(CoroutinePatterns.loggingExceptionHandler) {
val loader = ImageLoader(UApp.applicationContext(), client, config)
withContext(Dispatchers.Main) { cb(loader) }
}
}
companion object {
val config by lazy {
var defaultSize = 0
@@ -0,0 +1,77 @@
package org.moire.ultrasonic.data
import android.content.Context
import android.net.Uri
import androidx.media3.datasource.DataSource
import androidx.media3.datasource.DataSpec
import androidx.test.core.app.ApplicationProvider
import java.io.File
import org.amshove.kluent.shouldBeEqualTo
import org.junit.After
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.mockito.kotlin.any
import org.mockito.kotlin.anyOrNull
import org.mockito.kotlin.eq
import org.mockito.kotlin.mock
import org.mockito.kotlin.verify
import org.mockito.kotlin.whenever
import org.moire.ultrasonic.app.UApp
import org.moire.ultrasonic.util.FileUtil
import org.moire.ultrasonic.util.Storage
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [28])
class CachedDataSourceTest {
private lateinit var context: Context
@Before
fun setUp() {
val realContext = ApplicationProvider.getApplicationContext<Context>()
context = mock()
whenever(context.getString(any())).thenAnswer { it.arguments[0].toString() }
whenever(context.getSharedPreferences(any(), any())).thenAnswer {
realContext.getSharedPreferences(it.arguments[0] as String, it.arguments[1] as Int)
}
whenever(context.getExternalFilesDir(anyOrNull()))
.thenReturn(File(realContext.cacheDir, "media").apply { mkdirs() })
whenever(context.contentResolver).thenReturn(realContext.contentResolver)
val app: UApp = mock()
whenever(app.applicationContext).thenReturn(context)
UApp.instance = app
FileUtil.cachedUltrasonicDirectory = null
Storage.reset()
}
@After
fun tearDown() {
UApp.instance = null
Storage.reset()
}
@Test
fun `cache hit followed by miss reads from upstream`() {
val directory = ApplicationProvider.getApplicationContext<Context>().cacheDir
val cached = File(directory, "cached-song.mp3").apply {
writeBytes(byteArrayOf(1, 2))
}
val missing = File(directory, "missing-song.mp3")
missing.delete()
val upstream: DataSource = mock()
whenever(upstream.open(any())).thenReturn(3)
whenever(upstream.read(any(), eq(0), eq(3))).thenReturn(3)
val source = CachedDataSource(upstream)
source.open(DataSpec(Uri.parse("1|0|${cached.absolutePath}")))
source.read(ByteArray(2), 0, 2) shouldBeEqualTo 2
source.close()
source.open(DataSpec(Uri.parse("2|0|${missing.absolutePath}")))
source.read(ByteArray(3), 0, 3) shouldBeEqualTo 3
verify(upstream).read(any(), eq(0), eq(3))
source.close()
}
}
@@ -0,0 +1,35 @@
package org.moire.ultrasonic.data
import org.amshove.kluent.shouldBeEqualTo
import org.junit.Test
import org.mockito.kotlin.mock
import org.mockito.kotlin.verify
class MetaDatabaseRegistryTest {
@Test
fun `server switch reuses handles and deletion closes only its server`() {
val first: MetaDatabase = mock()
val second: MetaDatabase = mock()
val opened = mutableListOf<Int>()
val deleted = mutableListOf<Int>()
val registry = MetaDatabaseRegistry(
open = { id ->
opened += id
if (id == 1) first else second
},
deleteFile = { deleted += it }
)
registry.forServer(1) shouldBeEqualTo first
registry.forServer(2) shouldBeEqualTo second
registry.forServer(1) shouldBeEqualTo first
opened shouldBeEqualTo listOf(1, 2)
registry.delete(1)
verify(first).close()
deleted shouldBeEqualTo listOf(1)
registry.forServer(2) shouldBeEqualTo second
opened shouldBeEqualTo listOf(1, 2)
}
}
@@ -0,0 +1,62 @@
package org.moire.ultrasonic.model
import android.app.Application
import androidx.test.core.app.ApplicationProvider
import kotlinx.coroutines.runBlocking
import org.junit.After
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.mockito.kotlin.any
import org.mockito.kotlin.inOrder
import org.mockito.kotlin.mock
import org.mockito.kotlin.whenever
import org.moire.ultrasonic.app.UApp
import org.moire.ultrasonic.data.ActiveServerProvider
import org.moire.ultrasonic.data.ServerSetting
import org.moire.ultrasonic.data.ServerSettingDao
import org.moire.ultrasonic.service.MusicServiceFactory
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [28])
class ServerSettingsModelTest {
@Before
fun setUp() {
val appContext: Application = mock()
whenever(appContext.getString(any())).thenAnswer { it.arguments[0].toString() }
whenever(appContext.applicationContext).thenReturn(appContext)
val app: UApp = mock()
whenever(app.applicationContext).thenReturn(appContext)
UApp.instance = app
}
@After
fun tearDown() {
UApp.instance = null
}
@Test
fun `active server client is invalidated after settings and state are updated`() = runBlocking {
val repository: ServerSettingDao = mock()
val provider: ActiveServerProvider = mock()
val factory: MusicServiceFactory = mock()
val server = ServerSetting().apply { id = 7 }
whenever(provider.getSelectedServerId()).thenReturn(7)
whenever(provider.isOffline()).thenReturn(true)
val model = ServerSettingsModel(
repository,
provider,
factory,
ApplicationProvider.getApplicationContext<Application>()
)
model.updateItem(server)
val order = inOrder(repository, provider, factory)
order.verify(repository).update(server)
order.verify(provider).invalidateCache()
order.verify(factory).resetMusicService()
}
}
@@ -35,6 +35,7 @@ import org.moire.ultrasonic.util.FileUtil.getCompleteFile
import org.moire.ultrasonic.util.Storage
import org.robolectric.RobolectricTestRunner
import org.robolectric.Shadows.shadowOf
import org.robolectric.annotation.Config
import org.robolectric.shadows.ShadowEnvironment
/**
@@ -46,6 +47,7 @@ import org.robolectric.shadows.ShadowEnvironment
* download tasks are started against the mocked MusicServiceFactory.
*/
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [28])
class DownloadManagerTest {
private class FakeHost : DownloadManager.Host {
@@ -147,6 +149,29 @@ class DownloadManagerTest {
manager.inFlightDownloads().size shouldBeEqualTo 2
}
@Test
fun `queued item keeps its source after selected server changes`() {
runBlocking {
manager.downloadAsync(listOf(track("shared")))
}
whenever(activeServerProvider.getSelectedServerId()).thenReturn(2)
manager.inFlightDownloads().single().serverId shouldBeEqualTo 1
}
@Test
fun `deleting one server removes only its queued downloads`() {
runBlocking {
val first = track("first").copy(serverId = 1)
val second = track("second").copy(serverId = 2)
manager.downloadAsync(listOf(first, second))
manager.cancelForServer(1)
manager.inFlightDownloads().map { it.serverId } shouldBeEqualTo listOf(2)
}
}
@Test
fun `already downloaded tracks are skipped instead of queued`() {
runBlocking {
@@ -193,7 +218,7 @@ class DownloadManagerTest {
@Test
fun `retrying requeues the track and increments the try count`() {
val item = DownloadableTrack(track("5"), false, 0, 0)
val item = DownloadableTrack(track("5"), 1, false, 0, 0)
manager.downloadStateChangedCallback(item, DownloadState.RETRYING, null)
@@ -203,7 +228,7 @@ class DownloadManagerTest {
@Test
fun `failed downloads are remembered and removed by delete`() {
val item = DownloadableTrack(track("6"), false, 0, 0)
val item = DownloadableTrack(track("6"), 1, false, 0, 0)
manager.downloadStateChangedCallback(item, DownloadState.FAILED, null)
manager.getDownloadState(track("6")) shouldBeEqualTo DownloadState.FAILED
@@ -219,7 +244,7 @@ class DownloadManagerTest {
manager.attachService(host)
manager.detachService()
val item = DownloadableTrack(track("9"), false, 0, 0)
val item = DownloadableTrack(track("9"), 1, false, 0, 0)
manager.downloadStateChangedCallback(item, DownloadState.FAILED, null)
manager.getDownloadState(track("9")) shouldBeEqualTo DownloadState.FAILED
@@ -0,0 +1,21 @@
package org.moire.ultrasonic.service
import org.amshove.kluent.shouldBeEqualTo
import org.junit.Test
import org.moire.ultrasonic.domain.Track
class DownloadSourceTest {
@Test
fun `track keeps its source server when selection changes`() {
val track = Track(id = "same-id", serverId = 7)
track.downloadSourceServerId(selectedServerId = 9) shouldBeEqualTo 7
}
@Test
fun `legacy track captures selected server at enqueue`() {
val track = Track(id = "same-id")
track.downloadSourceServerId(selectedServerId = 7) shouldBeEqualTo 7
}
}
@@ -9,6 +9,7 @@ package org.moire.ultrasonic.service
import android.content.Context
import androidx.test.core.app.ApplicationProvider
import kotlinx.coroutines.runBlocking
import okhttp3.logging.HttpLoggingInterceptor
import org.amshove.kluent.shouldBeEqualTo
import org.amshove.kluent.shouldNotBeEqualTo
@@ -27,6 +28,7 @@ import org.moire.ultrasonic.data.ActiveServerProvider
import org.moire.ultrasonic.data.LibraryAccessState
import org.moire.ultrasonic.data.ServerSetting
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
/**
* Verifies the switch logic of the long-lived [MusicServiceFactory]: the delegate is
@@ -37,6 +39,7 @@ import org.robolectric.RobolectricTestRunner
* object, which reads Android settings and resources.
*/
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [28])
class MusicServiceFactoryTest {
companion object {
@@ -178,6 +181,32 @@ class MusicServiceFactoryTest {
builtDelegates.size shouldBeEqualTo 2
}
@Test
fun `server bound service uses its own server after selection changes`() {
runBlocking {
stubState(selectedServerId = 9, serverUrl = "https://selected.example.com")
val source = ServerSetting(
id = 7,
index = 1,
name = "source",
url = "https://source.example.com",
userName = "source-user",
password = "pass",
jukeboxByDefault = false,
allowSelfSignedCertificate = false,
forcePlainTextPassword = false,
musicFolderId = "",
minimumApiVersion = null
)
whenever(activeServerProvider.getServerById(7)).thenReturn(source)
val service = factory.forServer(7).musicService
service.getStreamUrl("song", null, null)
.orEmpty().startsWith("https://source.example.com/") shouldBeEqualTo true
}
}
@Test
fun `silent refresh keeps the delegate but resets nothing`() {
stubState()