diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/data/ActiveServerProvider.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/data/ActiveServerProvider.kt index 48200ed6..599c67c7 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/data/ActiveServerProvider.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/data/ActiveServerProvider.kt @@ -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) + } } } diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/data/CachedDataSource.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/data/CachedDataSource.kt index 596ce9a4..20d9dc0f 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/data/CachedDataSource.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/data/CachedDataSource.kt @@ -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 } } diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/data/MetaDatabaseRegistry.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/data/MetaDatabaseRegistry.kt new file mode 100644 index 00000000..704dda9c --- /dev/null +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/data/MetaDatabaseRegistry.kt @@ -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() + + @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) + } +} diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/di/AppPermanentStorageModule.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/di/AppPermanentStorageModule.kt index fbfe7195..5eda79cc 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/di/AppPermanentStorageModule.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/di/AppPermanentStorageModule.kt @@ -61,7 +61,7 @@ val appPermanentStorage = module { single { get().serverSettingDao() } - viewModel { ServerSettingsModel(get(), get(), get()) } + viewModel { ServerSettingsModel(get(), get(), get(), get()) } viewModel { EditServerModel(get()) } viewModel { ChatViewModel() } diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/EditServerFragment.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/EditServerFragment.kt index 146adff3..8a39dec0 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/EditServerFragment.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/EditServerFragment.kt @@ -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() } } } diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/ServerSelectorFragment.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/ServerSelectorFragment.kt index 8e91fbd9..b02fa33d 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/ServerSelectorFragment.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/ServerSelectorFragment.kt @@ -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() diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/model/ServerSettingsModel.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/model/ServerSettingsModel.kt index 5d24c53d..8f8a7085 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/model/ServerSettingsModel.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/model/ServerSettingsModel.kt @@ -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") } /** diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/DownloadManager.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/DownloadManager.kt index 7f437f5c..b1021600 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/DownloadManager.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/DownloadManager.kt @@ -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) diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/DownloadTask.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/DownloadTask.kt index 275e36f8..5c21fbcc 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/DownloadTask.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/DownloadTask.kt @@ -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() ) diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/DownloadableTrack.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/DownloadableTrack.kt index 219a5bbf..3dc59846 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/DownloadableTrack.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/DownloadableTrack.kt @@ -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 diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/MusicServiceFactory.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/MusicServiceFactory.kt index ba7da52c..adb68d5d 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/MusicServiceFactory.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/MusicServiceFactory.kt @@ -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? = @@ -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 get() { val state = activeServerProvider.getLibraryAccessState() diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/RESTMusicService.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/RESTMusicService.kt index 5afe6a96..580074d0 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/RESTMusicService.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/RESTMusicService.kt @@ -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) } } } diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/subsonic/ImageLoaderProvider.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/subsonic/ImageLoaderProvider.kt index d9cd6fa3..ee5d8d91 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/subsonic/ImageLoaderProvider.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/subsonic/ImageLoaderProvider.kt @@ -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 diff --git a/ultrasonic/src/test/kotlin/org/moire/ultrasonic/data/CachedDataSourceTest.kt b/ultrasonic/src/test/kotlin/org/moire/ultrasonic/data/CachedDataSourceTest.kt new file mode 100644 index 00000000..fd924c7e --- /dev/null +++ b/ultrasonic/src/test/kotlin/org/moire/ultrasonic/data/CachedDataSourceTest.kt @@ -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 = 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().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() + } +} diff --git a/ultrasonic/src/test/kotlin/org/moire/ultrasonic/data/MetaDatabaseRegistryTest.kt b/ultrasonic/src/test/kotlin/org/moire/ultrasonic/data/MetaDatabaseRegistryTest.kt new file mode 100644 index 00000000..18cb6e82 --- /dev/null +++ b/ultrasonic/src/test/kotlin/org/moire/ultrasonic/data/MetaDatabaseRegistryTest.kt @@ -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() + val deleted = mutableListOf() + 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) + } +} diff --git a/ultrasonic/src/test/kotlin/org/moire/ultrasonic/model/ServerSettingsModelTest.kt b/ultrasonic/src/test/kotlin/org/moire/ultrasonic/model/ServerSettingsModelTest.kt new file mode 100644 index 00000000..a734be7e --- /dev/null +++ b/ultrasonic/src/test/kotlin/org/moire/ultrasonic/model/ServerSettingsModelTest.kt @@ -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() + ) + + model.updateItem(server) + + val order = inOrder(repository, provider, factory) + order.verify(repository).update(server) + order.verify(provider).invalidateCache() + order.verify(factory).resetMusicService() + } +} diff --git a/ultrasonic/src/test/kotlin/org/moire/ultrasonic/service/DownloadManagerTest.kt b/ultrasonic/src/test/kotlin/org/moire/ultrasonic/service/DownloadManagerTest.kt index 390d2523..6ba5eef5 100644 --- a/ultrasonic/src/test/kotlin/org/moire/ultrasonic/service/DownloadManagerTest.kt +++ b/ultrasonic/src/test/kotlin/org/moire/ultrasonic/service/DownloadManagerTest.kt @@ -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 diff --git a/ultrasonic/src/test/kotlin/org/moire/ultrasonic/service/DownloadSourceTest.kt b/ultrasonic/src/test/kotlin/org/moire/ultrasonic/service/DownloadSourceTest.kt new file mode 100644 index 00000000..e820a138 --- /dev/null +++ b/ultrasonic/src/test/kotlin/org/moire/ultrasonic/service/DownloadSourceTest.kt @@ -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 + } +} diff --git a/ultrasonic/src/test/kotlin/org/moire/ultrasonic/service/MusicServiceFactoryTest.kt b/ultrasonic/src/test/kotlin/org/moire/ultrasonic/service/MusicServiceFactoryTest.kt index 2b08e16a..393210aa 100644 --- a/ultrasonic/src/test/kotlin/org/moire/ultrasonic/service/MusicServiceFactoryTest.kt +++ b/ultrasonic/src/test/kotlin/org/moire/ultrasonic/service/MusicServiceFactoryTest.kt @@ -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()