music: fix comparison issues

Fix a few problems with the current comparison algorithm:
1. It wasn't actually comparing the raw music information, only the
UIDs, which was redundant.
2. The comparison in the main music loading process occurred on the
main thread, which causes massive freeze-up issues.

Resolves #457.
This commit is contained in:
Alexander Capehart 2023-06-01 15:05:24 -06:00
parent 46fb33de59
commit f9ccb831d8
No known key found for this signature in database
GPG key ID: 37DBE3621FE9AD47
8 changed files with 118 additions and 95 deletions

View file

@ -18,6 +18,8 @@
within it
- Fixed blurry playing indicator in album/artist/genre/playlist items
- Fixed incorrect songs being displayed when adding albums to the end of the queue
- Fixed freezing occuring when scrolling through large music libraries
- Fixed app not responding once music loading completes for large libraries
#### What's Changed
- Android Lollipop and Marshmallow support have been dropped

View file

@ -118,6 +118,7 @@ constructor(
* the given [Album] in the given [Song] list.
*/
fun computeCoverOrdering(songs: List<Song>): List<Album> {
// TODO: Start short-circuiting in more places
if (songs.isEmpty()) return listOf()
if (songs.size == 1) return listOf(songs.first().album)

View file

@ -32,7 +32,6 @@ import kotlinx.coroutines.Job
import kotlinx.coroutines.async
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.coroutines.yield
import org.oxycblt.auxio.music.cache.CacheRepository
import org.oxycblt.auxio.music.device.DeviceLibrary
@ -301,44 +300,35 @@ constructor(
val userLibrary = synchronized(this) { userLibrary ?: return }
logD("Creating playlist $name with ${songs.size} songs")
userLibrary.createPlaylist(name, songs)
notifyUserLibraryChange()
emitLibraryChange(device = false, user = true)
}
override suspend fun renamePlaylist(playlist: Playlist, name: String) {
val userLibrary = synchronized(this) { userLibrary ?: return }
logD("Renaming $playlist to $name")
userLibrary.renamePlaylist(playlist, name)
notifyUserLibraryChange()
emitLibraryChange(device = false, user = true)
}
override suspend fun deletePlaylist(playlist: Playlist) {
val userLibrary = synchronized(this) { userLibrary ?: return }
logD("Deleting $playlist")
userLibrary.deletePlaylist(playlist)
notifyUserLibraryChange()
emitLibraryChange(device = false, user = true)
}
override suspend fun addToPlaylist(songs: List<Song>, playlist: Playlist) {
val userLibrary = synchronized(this) { userLibrary ?: return }
logD("Adding ${songs.size} songs to $playlist")
userLibrary.addToPlaylist(playlist, songs)
notifyUserLibraryChange()
emitLibraryChange(device = false, user = true)
}
override suspend fun rewritePlaylist(playlist: Playlist, songs: List<Song>) {
val userLibrary = synchronized(this) { userLibrary ?: return }
logD("Rewriting $playlist with ${songs.size} songs")
userLibrary.rewritePlaylist(playlist, songs)
notifyUserLibraryChange()
}
@Synchronized
private fun notifyUserLibraryChange() {
logD("Dispatching user library change")
for (listener in updateListeners) {
listener.onMusicChanges(
MusicRepository.Changes(deviceLibrary = false, userLibrary = true))
}
emitLibraryChange(device = false, user = true)
}
@Synchronized
@ -435,7 +425,7 @@ constructor(
val deviceLibraryChannel = Channel<DeviceLibrary>()
logD("Starting DeviceLibrary creation")
val deviceLibraryJob =
worker.scope.tryAsync(Dispatchers.Main) {
worker.scope.tryAsync(Dispatchers.Default) {
deviceLibraryFactory.create(rawSongs).also { deviceLibraryChannel.send(it) }
}
logD("Starting UserLibrary creation")
@ -452,10 +442,22 @@ constructor(
val userLibrary = userLibraryJob.await().getOrThrow()
logD("Successfully indexed music library [device=$deviceLibrary user=$userLibrary]")
withContext(Dispatchers.Main) {
emitComplete(null)
emitData(deviceLibrary, userLibrary)
emitComplete(null)
// Comparing the library instances is obscenely expensive, do it within the library
val deviceLibraryChanged = this.deviceLibrary != deviceLibrary
val userLibraryChanged = this.userLibrary != userLibrary
if (!deviceLibraryChanged && !userLibraryChanged) {
logD("Library has not changed, skipping update")
return
}
synchronized(this) {
this.deviceLibrary = deviceLibrary
this.userLibrary = userLibrary
}
emitLibraryChange(deviceLibraryChanged, userLibraryChanged)
}
/**
@ -497,14 +499,8 @@ constructor(
}
@Synchronized
private fun emitData(deviceLibrary: DeviceLibrary, userLibrary: MutableUserLibrary) {
val deviceLibraryChanged = this.deviceLibrary != deviceLibrary
val userLibraryChanged = this.userLibrary != userLibrary
if (!deviceLibraryChanged && !userLibraryChanged) return
this.deviceLibrary = deviceLibrary
this.userLibrary = userLibrary
val changes = MusicRepository.Changes(deviceLibraryChanged, userLibraryChanged)
private fun emitLibraryChange(device: Boolean, user: Boolean) {
val changes = MusicRepository.Changes(device, user)
logD("Dispatching library change [changes=$changes]")
for (listener in updateListeners) {
listener.onMusicChanges(changes)

View file

@ -193,8 +193,8 @@ private class DeviceLibraryImpl(rawSongs: List<RawSong>, settings: MusicSettings
private fun buildAlbums(songs: List<SongImpl>, settings: MusicSettings): List<AlbumImpl> {
// Group songs by their singular raw album, then map the raw instances and their
// grouped songs to Album values. Album.Raw will handle the actual grouping rules.
val songsByAlbum = songs.groupBy { it.rawAlbum }
val albums = songsByAlbum.map { AlbumImpl(it.key, settings, it.value) }
val songsByAlbum = songs.groupBy { it.rawAlbum.key }
val albums = songsByAlbum.map { AlbumImpl(it.key.value, settings, it.value) }
logD("Successfully built ${albums.size} albums")
return albums
}
@ -221,22 +221,22 @@ private class DeviceLibraryImpl(rawSongs: List<RawSong>, settings: MusicSettings
): List<ArtistImpl> {
// Add every raw artist credited to each Song/Album to the grouping. This way,
// different multi-artist combinations are not treated as different artists.
val musicByArtist = mutableMapOf<RawArtist, MutableList<Music>>()
val musicByArtist = mutableMapOf<RawArtist.Key, MutableList<Music>>()
for (song in songs) {
for (rawArtist in song.rawArtists) {
musicByArtist.getOrPut(rawArtist) { mutableListOf() }.add(song)
musicByArtist.getOrPut(rawArtist.key) { mutableListOf() }.add(song)
}
}
for (album in albums) {
for (rawArtist in album.rawArtists) {
musicByArtist.getOrPut(rawArtist) { mutableListOf() }.add(album)
musicByArtist.getOrPut(rawArtist.key) { mutableListOf() }.add(album)
}
}
// Convert the combined mapping into artist instances.
val artists = musicByArtist.map { ArtistImpl(it.key, settings, it.value) }
val artists = musicByArtist.map { ArtistImpl(it.key.value, settings, it.value) }
logD("Successfully built ${artists.size} artists")
return artists
}
@ -253,15 +253,15 @@ private class DeviceLibraryImpl(rawSongs: List<RawSong>, settings: MusicSettings
private fun buildGenres(songs: List<SongImpl>, settings: MusicSettings): List<GenreImpl> {
// Add every raw genre credited to each Song to the grouping. This way,
// different multi-genre combinations are not treated as different genres.
val songsByGenre = mutableMapOf<RawGenre, MutableList<SongImpl>>()
val songsByGenre = mutableMapOf<RawGenre.Key, MutableList<SongImpl>>()
for (song in songs) {
for (rawGenre in song.rawGenres) {
songsByGenre.getOrPut(rawGenre) { mutableListOf() }.add(song)
songsByGenre.getOrPut(rawGenre.key) { mutableListOf() }.add(song)
}
}
// Convert the mapping into genre instances.
val genres = songsByGenre.map { GenreImpl(it.key, settings, it.value) }
val genres = songsByGenre.map { GenreImpl(it.key.value, settings, it.value) }
logD("Successfully built ${genres.size} genres")
return genres
}

View file

@ -407,7 +407,8 @@ class ArtistImpl(
* [RawArtist] will be within the list.
* @return The index of the [Artist]'s [RawArtist] within the list.
*/
fun getOriginalPositionIn(rawArtists: List<RawArtist>) = rawArtists.indexOf(rawArtist)
fun getOriginalPositionIn(rawArtists: List<RawArtist>) =
rawArtists.indexOfFirst { it.key == rawArtist.key }
/**
* Perform final validation and organization on this instance.
@ -481,7 +482,8 @@ class GenreImpl(
* [RawGenre] will be within the list.
* @return The index of the [Genre]'s [RawGenre] within the list.
*/
fun getOriginalPositionIn(rawGenres: List<RawGenre>) = rawGenres.indexOf(rawGenre)
fun getOriginalPositionIn(rawGenres: List<RawGenre>) =
rawGenres.indexOfFirst { it.key == rawGenre.key }
/**
* Perform final validation and organization on this instance.

View file

@ -113,28 +113,35 @@ data class RawAlbum(
/** @see RawArtist.name */
val rawArtists: List<RawArtist>
) {
// Albums are grouped as follows:
// - If we have a MusicBrainz ID, only group by it. This allows different Albums with the
// same name to be differentiated, which is common in large libraries.
// - If we do not have a MusicBrainz ID, compare by the lowercase album name and lowercase
// artist name. This allows for case-insensitive artist/album grouping, which can be common
// for albums/artists that have different naming (ex. "RAMMSTEIN" vs. "Rammstein").
val key = Key(this)
// Cache the hash-code for HashMap efficiency.
private val hashCode =
musicBrainzId?.hashCode() ?: (31 * name.lowercase().hashCode() + rawArtists.hashCode())
/** Exposed information that denotes [RawAlbum] uniqueness. */
data class Key(val value: RawAlbum) {
// Albums are grouped as follows:
// - If we have a MusicBrainz ID, only group by it. This allows different Albums with the
// same name to be differentiated, which is common in large libraries.
// - If we do not have a MusicBrainz ID, compare by the lowercase album name and lowercase
// artist name. This allows for case-insensitive artist/album grouping, which can be common
// for albums/artists that have different naming (ex. "RAMMSTEIN" vs. "Rammstein").
override fun hashCode() = hashCode
// Cache the hash-code for HashMap efficiency.
private val hashCode =
value.musicBrainzId?.hashCode()
?: (31 * value.name.lowercase().hashCode() + value.rawArtists.hashCode())
override fun equals(other: Any?) =
other is RawAlbum &&
when {
musicBrainzId != null && other.musicBrainzId != null ->
musicBrainzId == other.musicBrainzId
musicBrainzId == null && other.musicBrainzId == null ->
name.equals(other.name, true) && rawArtists == other.rawArtists
else -> false
}
override fun hashCode() = hashCode
override fun equals(other: Any?) =
other is Key &&
when {
value.musicBrainzId != null && other.value.musicBrainzId != null ->
value.musicBrainzId == other.value.musicBrainzId
value.musicBrainzId == null && other.value.musicBrainzId == null ->
other.value.name.equals(other.value.name, true) &&
other.value.rawArtists == other.value.rawArtists
else -> false
}
}
}
/**
@ -151,33 +158,42 @@ data class RawArtist(
/** @see Music.name */
val sortName: String? = null
) {
// Artists are grouped as follows:
// - If we have a MusicBrainz ID, only group by it. This allows different Artists with the
// same name to be differentiated, which is common in large libraries.
// - If we do not have a MusicBrainz ID, compare by the lowercase name. This allows artist
// grouping to be case-insensitive.
val key = Key(this)
// Cache the hashCode for HashMap efficiency.
private val hashCode = musicBrainzId?.hashCode() ?: name?.lowercase().hashCode()
/**
* Allows [RawArtist]s to be compared by "fundamental" information that is unlikely to change on
* an item-by-item
*/
data class Key(val value: RawArtist) {
// Artists are grouped as follows:
// - If we have a MusicBrainz ID, only group by it. This allows different Artists with the
// same name to be differentiated, which is common in large libraries.
// - If we do not have a MusicBrainz ID, compare by the lowercase name. This allows artist
// grouping to be case-insensitive.
// Compare names and MusicBrainz IDs in order to differentiate artists with the
// same name in large libraries.
// Cache the hashCode for HashMap efficiency.
private val hashCode = value.musicBrainzId?.hashCode() ?: value.name?.lowercase().hashCode()
override fun hashCode() = hashCode
// Compare names and MusicBrainz IDs in order to differentiate artists with the
// same name in large libraries.
override fun equals(other: Any?) =
other is RawArtist &&
when {
musicBrainzId != null && other.musicBrainzId != null ->
musicBrainzId == other.musicBrainzId
musicBrainzId == null && other.musicBrainzId == null ->
when {
name != null && other.name != null -> name.equals(other.name, true)
name == null && other.name == null -> true
else -> false
}
else -> false
}
override fun hashCode() = hashCode
override fun equals(other: Any?) =
other is Key &&
when {
value.musicBrainzId != null && other.value.musicBrainzId != null ->
value.musicBrainzId == other.value.musicBrainzId
value.musicBrainzId == null && other.value.musicBrainzId == null ->
when {
value.name != null && other.value.name != null ->
value.name.equals(other.value.name, true)
value.name == null && other.value.name == null -> true
else -> false
}
else -> false
}
}
}
/**
@ -189,20 +205,24 @@ data class RawGenre(
/** @see Music.name */
val name: String? = null
) {
val key = Key(this)
// Cache the hashCode for HashMap efficiency.
private val hashCode = name?.lowercase().hashCode()
data class Key(val value: RawGenre) {
// Cache the hashCode for HashMap efficiency.
private val hashCode = value.name?.lowercase().hashCode()
// Only group by the lowercase genre name. This allows Genre grouping to be
// case-insensitive, which may be helpful in some libraries with different ways of
// formatting genres.
override fun hashCode() = hashCode
// Only group by the lowercase genre name. This allows Genre grouping to be
// case-insensitive, which may be helpful in some libraries with different ways of
// formatting genres.
override fun hashCode() = hashCode
override fun equals(other: Any?) =
other is RawGenre &&
when {
name != null && other.name != null -> name.equals(other.name, true)
name == null && other.name == null -> true
else -> false
}
override fun equals(other: Any?) =
other is Key &&
when {
value.name != null && other.value.name != null ->
value.name.equals(other.value.name, true)
value.name == null && other.value.name == null -> true
else -> false
}
}
}

View file

@ -149,6 +149,8 @@ private class UserLibraryImpl(
private val playlistMap: MutableMap<Music.UID, PlaylistImpl>,
private val musicSettings: MusicSettings
) : MutableUserLibrary {
override fun hashCode() = playlistMap.hashCode()
override fun equals(other: Any?) = other is UserLibraryImpl && other.playlistMap == playlistMap
override fun toString() = "UserLibrary(playlists=${playlists.size})"
override val playlists: List<Playlist>

View file

@ -107,8 +107,8 @@ class PlaybackService :
// Coroutines
private val serviceJob = Job()
private val restoreScope = CoroutineScope(serviceJob + Dispatchers.Main)
private val saveScope = CoroutineScope(serviceJob + Dispatchers.Main)
private val restoreScope = CoroutineScope(serviceJob + Dispatchers.IO)
private val saveScope = CoroutineScope(serviceJob + Dispatchers.IO)
// --- SERVICE OVERRIDES ---