From b24e22182e5b8987056e7427c40897271358f1c1 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Sat, 3 Sep 2022 16:46:53 -0600 Subject: [PATCH] music: add basic multi-genre support [#201] Add basic support for multiple genres. This is sort of the test run for full multi-artist support, allowing me to rework my abstractions to handle the presence of multiple parents. This is nowhere near complete. For example, there is currently a stopgap measure in the playback system that basically breaks genre playback. It's a start though. --- .../detail/recycler/ArtistDetailAdapter.kt | 14 +++++---- .../java/org/oxycblt/auxio/music/Music.kt | 29 ++++++++++++------- .../java/org/oxycblt/auxio/music/MusicUtil.kt | 7 +++-- .../auxio/music/system/ExoPlayerBackend.kt | 20 ++++++------- .../org/oxycblt/auxio/music/system/Indexer.kt | 11 ++++--- .../auxio/music/system/MediaStoreBackend.kt | 2 +- .../playback/state/PlaybackStateManager.kt | 3 +- .../playback/system/MediaSessionComponent.kt | 4 ++- 8 files changed, 55 insertions(+), 35 deletions(-) diff --git a/app/src/main/java/org/oxycblt/auxio/detail/recycler/ArtistDetailAdapter.kt b/app/src/main/java/org/oxycblt/auxio/detail/recycler/ArtistDetailAdapter.kt index 9c22ec80d..9f1357761 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/recycler/ArtistDetailAdapter.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/recycler/ArtistDetailAdapter.kt @@ -27,6 +27,7 @@ import org.oxycblt.auxio.databinding.ItemParentBinding import org.oxycblt.auxio.databinding.ItemSongBinding import org.oxycblt.auxio.music.Album import org.oxycblt.auxio.music.Artist +import org.oxycblt.auxio.music.Genre import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.music.resolveYear import org.oxycblt.auxio.ui.recycler.ArtistViewHolder @@ -111,12 +112,15 @@ private class ArtistDetailViewHolder private constructor(private val binding: It // Get the genre that corresponds to the most songs in this artist, which would be // the most "Prominent" genre. + var genresByAmount = mutableMapOf() + for (song in item.songs) { + for (genre in song.genres) { + genresByAmount[genre] = genresByAmount[genre]?.inc() ?: 1 + } + } + binding.detailSubhead.text = - item.songs - .groupBy { it.genre.resolveName(binding.context) } - .entries - .maxByOrNull { it.value.size } - ?.key + genresByAmount.maxByOrNull { it.value }?.key?.resolveName(binding.context) ?: binding.context.getString(R.string.def_genre) binding.detailInfo.text = diff --git a/app/src/main/java/org/oxycblt/auxio/music/Music.kt b/app/src/main/java/org/oxycblt/auxio/music/Music.kt index fabfdb7b8..ab3ff58fe 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/Music.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/Music.kt @@ -123,10 +123,10 @@ data class Song(private val raw: Raw) : Music() { val album: Album get() = unlikelyToBeNull(_album) - private var _genre: Genre? = null + private var _genres: MutableList = mutableListOf() /** The genre of this song. Will be an "unknown genre" if the song does not have any. */ - val genre: Genre - get() = unlikelyToBeNull(_genre) + val genres: List + get() = _genres /** * The raw artist name for this song in particular. First uses the artist tag, and then falls @@ -149,14 +149,14 @@ data class Song(private val raw: Raw) : Music() { raw.albumName to raw.artistName to raw.albumArtistName to - raw.genreName to + raw.genreNames to track to disc to durationMs val _rawAlbum: Album.Raw - val _rawGenre = Genre.Raw(raw.genreName) + val _rawGenres = raw.genreNames?.map { Genre.Raw(it) } ?: listOf(Genre.Raw(null)) val _isMissingAlbum: Boolean get() = _album == null @@ -165,14 +165,14 @@ data class Song(private val raw: Raw) : Music() { get() = _album?._isMissingArtist ?: true val _isMissingGenre: Boolean - get() = _genre == null + get() = _genres.isEmpty() fun _link(album: Album) { _album = album } fun _link(genre: Genre) { - _genre = genre + _genres.add(genre) } init { @@ -220,7 +220,7 @@ data class Song(private val raw: Raw) : Music() { var artistSortName: String? = null, var albumArtistName: String? = null, var albumArtistSortName: String? = null, - var genreName: String? = null + var genreNames: List? = null ) } @@ -358,7 +358,16 @@ data class Genre(private val raw: Raw, override val songs: List) : MusicPa val durationMs = songs.sumOf { it.durationMs } data class Raw(val name: String?) { - val groupingId: Long = name.toMusicId() + override fun equals(other: Any?): Boolean { + if (other !is Raw) return false + return when { + name != null && other.name != null -> name.equals(other.name, true) + name == null && other.name == null -> true + else -> false + } + } + + override fun hashCode() = name?.lowercase().hashCode() } } @@ -370,7 +379,7 @@ private fun String?.toMusicId(): Long { var result = 0L for (ch in lowercase()) { - result = 31 * result + ch.code + result = 31 * result + ch.lowercaseChar().code } return result } diff --git a/app/src/main/java/org/oxycblt/auxio/music/MusicUtil.kt b/app/src/main/java/org/oxycblt/auxio/music/MusicUtil.kt index 1a56c957e..7346d26c4 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/MusicUtil.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/MusicUtil.kt @@ -110,7 +110,8 @@ fun List.parseReleaseType() = ReleaseType.parse(this) * Decodes the genre name from an ID3(v2) constant. See [GENRE_TABLE] for the genre constant map * that Auxio uses. */ -fun String.parseId3GenreName() = parseId3v1Genre() ?: parseId3v2Genre() ?: this +fun String.parseId3GenreName() = + parseId3v1Genre()?.let { listOf(it) } ?: parseId3v2Genre() ?: listOf(this) private fun String.parseId3v1Genre(): String? = when { @@ -126,7 +127,7 @@ private fun String.parseId3v1Genre(): String? = else -> null } -private fun String.parseId3v2Genre(): String? { +private fun String.parseId3v2Genre(): List? { val groups = (GENRE_RE.matchEntire(this) ?: return null).groupValues val genres = mutableSetOf() @@ -155,7 +156,7 @@ private fun String.parseId3v2Genre(): String? { } } - return genres.joinToString(separator = ", ").ifEmpty { null } + return genres.toList() } /** Regex that implements matching for ID3v2's genre format. */ diff --git a/app/src/main/java/org/oxycblt/auxio/music/system/ExoPlayerBackend.kt b/app/src/main/java/org/oxycblt/auxio/music/system/ExoPlayerBackend.kt index 6a40a132b..1832b07fc 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/system/ExoPlayerBackend.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/system/ExoPlayerBackend.kt @@ -50,7 +50,7 @@ import org.oxycblt.auxio.util.logW * @author OxygenCobalt */ class ExoPlayerBackend(private val inner: MediaStoreBackend) : Indexer.Backend { - private val runningTasks: Array = arrayOfNulls(TASK_CAPACITY) + private val taskPool: Array = arrayOfNulls(TASK_CAPACITY) // No need to implement our own query logic, as this backend is still reliant on // MediaStore. @@ -78,19 +78,19 @@ class ExoPlayerBackend(private val inner: MediaStoreBackend) : Indexer.Backend { // executor and thus will crash the app if an error occurs instead of bubbling // back up to Indexer. spin@ while (true) { - for (i in runningTasks.indices) { - val task = runningTasks[i] + for (i in taskPool.indices) { + val task = taskPool[i] if (task != null) { val song = task.get() if (song != null) { songs.add(song) emitIndexing(Indexer.Indexing.Songs(songs.size, total)) - runningTasks[i] = Task(context, raw) + taskPool[i] = Task(context, raw) break@spin } } else { - runningTasks[i] = Task(context, raw) + taskPool[i] = Task(context, raw) break@spin } } @@ -99,14 +99,14 @@ class ExoPlayerBackend(private val inner: MediaStoreBackend) : Indexer.Backend { spin@ while (true) { // Spin until all of the remaining tasks are complete. - for (i in runningTasks.indices) { - val task = runningTasks[i] + for (i in taskPool.indices) { + val task = taskPool[i] if (task != null) { val song = task.get() ?: continue@spin songs.add(song) emitIndexing(Indexer.Indexing.Songs(songs.size, total)) - runningTasks[i] = null + taskPool[i] = null } } @@ -249,7 +249,7 @@ class Task(context: Context, private val raw: Song.Raw) { tags["TSO2"]?.let { raw.albumArtistSortName = it } // Genre, with the weird ID3 rules. - tags["TCON"]?.let { raw.genreName = it.parseId3GenreName() } + tags["TCON"]?.let { raw.genreNames = it.parseId3GenreName() } // Release type (GRP1 is sometimes used for this, so fall back to it) (tags["TXXX:MusicBrainz Album Type"] ?: tags["GRP1"])?.parseReleaseType()?.let { @@ -313,7 +313,7 @@ class Task(context: Context, private val raw: Song.Raw) { tags["ALBUMARTISTSORT"]?.let { raw.albumArtistSortName = it.joinToString() } // Genre, no ID3 rules here - tags["GENRE"]?.let { raw.genreName = it.joinToString() } + tags["GENRE"]?.let { raw.genreNames = it } // Release type tags["RELEASETYPE"]?.parseReleaseType()?.let { raw.albumReleaseType = it } diff --git a/app/src/main/java/org/oxycblt/auxio/music/system/Indexer.kt b/app/src/main/java/org/oxycblt/auxio/music/system/Indexer.kt index 95842213f..577730571 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/system/Indexer.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/system/Indexer.kt @@ -332,12 +332,15 @@ class Indexer { */ private fun buildGenres(songs: List): List { val genres = mutableListOf() - val songsByGenre = songs.groupBy { it._rawGenre.groupingId } + val songsByGenre = mutableMapOf>() + for (song in songs) { + for (rawGenre in song._rawGenres) { + songsByGenre.getOrPut(rawGenre) { mutableListOf() }.add(song) + } + } for (entry in songsByGenre) { - // The first song fill suffice for template metadata. - val templateSong = entry.value[0] - genres.add(Genre(templateSong._rawGenre, songs = entry.value)) + genres.add(Genre(entry.key, songs = entry.value)) } logD("Successfully built ${genres.size} genres") diff --git a/app/src/main/java/org/oxycblt/auxio/music/system/MediaStoreBackend.kt b/app/src/main/java/org/oxycblt/auxio/music/system/MediaStoreBackend.kt index 89ec99574..5d96d9713 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/system/MediaStoreBackend.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/system/MediaStoreBackend.kt @@ -201,7 +201,7 @@ abstract class MediaStoreBackend : Indexer.Backend { val songId = cursor.getLong(songIdIndex) rawSongs .find { it.mediaStoreId == songId } - ?.let { song -> song.genreName = name } + ?.let { song -> song.genreNames = name } if (cursor.position % 50 == 0) { // Only check for a cancellation every 50 songs or so (~20ms). diff --git a/app/src/main/java/org/oxycblt/auxio/playback/state/PlaybackStateManager.kt b/app/src/main/java/org/oxycblt/auxio/playback/state/PlaybackStateManager.kt index 2de039461..1bee176ac 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/state/PlaybackStateManager.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/state/PlaybackStateManager.kt @@ -162,7 +162,8 @@ class PlaybackStateManager private constructor() { PlaybackMode.ALL_SONGS -> null PlaybackMode.IN_ALBUM -> song.album PlaybackMode.IN_ARTIST -> song.album.artist - PlaybackMode.IN_GENRE -> song.genre + PlaybackMode.IN_GENRE -> + song.genres.maxBy { it.songs.size } // TODO: Stopgap measure until I can rework this and add selection } applyNewQueue(library, settings, settings.keepShuffle && isShuffled, song) diff --git a/app/src/main/java/org/oxycblt/auxio/playback/system/MediaSessionComponent.kt b/app/src/main/java/org/oxycblt/auxio/playback/system/MediaSessionComponent.kt index 120d0ee5e..b02d03a21 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/system/MediaSessionComponent.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/system/MediaSessionComponent.kt @@ -160,7 +160,9 @@ class MediaSessionComponent( .putText(MediaMetadataCompat.METADATA_KEY_AUTHOR, artist) .putText(MediaMetadataCompat.METADATA_KEY_COMPOSER, artist) .putText(MediaMetadataCompat.METADATA_KEY_WRITER, artist) - .putText(MediaMetadataCompat.METADATA_KEY_GENRE, song.genre.resolveName(context)) + .putText( + MediaMetadataCompat.METADATA_KEY_GENRE, + song.genres.joinToString { it.resolveName(context) }) .putText( METADATA_KEY_PARENT, parent?.resolveName(context) ?: context.getString(R.string.lbl_all_songs))