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.
This commit is contained in:
Alexander Capehart 2022-09-03 16:46:53 -06:00
parent 022f92f27f
commit b24e22182e
No known key found for this signature in database
GPG key ID: 37DBE3621FE9AD47
8 changed files with 55 additions and 35 deletions

View file

@ -27,6 +27,7 @@ import org.oxycblt.auxio.databinding.ItemParentBinding
import org.oxycblt.auxio.databinding.ItemSongBinding import org.oxycblt.auxio.databinding.ItemSongBinding
import org.oxycblt.auxio.music.Album import org.oxycblt.auxio.music.Album
import org.oxycblt.auxio.music.Artist import org.oxycblt.auxio.music.Artist
import org.oxycblt.auxio.music.Genre
import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.music.resolveYear import org.oxycblt.auxio.music.resolveYear
import org.oxycblt.auxio.ui.recycler.ArtistViewHolder 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 // Get the genre that corresponds to the most songs in this artist, which would be
// the most "Prominent" genre. // the most "Prominent" genre.
var genresByAmount = mutableMapOf<Genre, Int>()
for (song in item.songs) {
for (genre in song.genres) {
genresByAmount[genre] = genresByAmount[genre]?.inc() ?: 1
}
}
binding.detailSubhead.text = binding.detailSubhead.text =
item.songs genresByAmount.maxByOrNull { it.value }?.key?.resolveName(binding.context)
.groupBy { it.genre.resolveName(binding.context) }
.entries
.maxByOrNull { it.value.size }
?.key
?: binding.context.getString(R.string.def_genre) ?: binding.context.getString(R.string.def_genre)
binding.detailInfo.text = binding.detailInfo.text =

View file

@ -123,10 +123,10 @@ data class Song(private val raw: Raw) : Music() {
val album: Album val album: Album
get() = unlikelyToBeNull(_album) get() = unlikelyToBeNull(_album)
private var _genre: Genre? = null private var _genres: MutableList<Genre> = mutableListOf()
/** The genre of this song. Will be an "unknown genre" if the song does not have any. */ /** The genre of this song. Will be an "unknown genre" if the song does not have any. */
val genre: Genre val genres: List<Genre>
get() = unlikelyToBeNull(_genre) get() = _genres
/** /**
* The raw artist name for this song in particular. First uses the artist tag, and then falls * 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.albumName to
raw.artistName to raw.artistName to
raw.albumArtistName to raw.albumArtistName to
raw.genreName to raw.genreNames to
track to track to
disc to disc to
durationMs durationMs
val _rawAlbum: Album.Raw 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 val _isMissingAlbum: Boolean
get() = _album == null get() = _album == null
@ -165,14 +165,14 @@ data class Song(private val raw: Raw) : Music() {
get() = _album?._isMissingArtist ?: true get() = _album?._isMissingArtist ?: true
val _isMissingGenre: Boolean val _isMissingGenre: Boolean
get() = _genre == null get() = _genres.isEmpty()
fun _link(album: Album) { fun _link(album: Album) {
_album = album _album = album
} }
fun _link(genre: Genre) { fun _link(genre: Genre) {
_genre = genre _genres.add(genre)
} }
init { init {
@ -220,7 +220,7 @@ data class Song(private val raw: Raw) : Music() {
var artistSortName: String? = null, var artistSortName: String? = null,
var albumArtistName: String? = null, var albumArtistName: String? = null,
var albumArtistSortName: String? = null, var albumArtistSortName: String? = null,
var genreName: String? = null var genreNames: List<String>? = null
) )
} }
@ -358,7 +358,16 @@ data class Genre(private val raw: Raw, override val songs: List<Song>) : MusicPa
val durationMs = songs.sumOf { it.durationMs } val durationMs = songs.sumOf { it.durationMs }
data class Raw(val name: String?) { 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 var result = 0L
for (ch in lowercase()) { for (ch in lowercase()) {
result = 31 * result + ch.code result = 31 * result + ch.lowercaseChar().code
} }
return result return result
} }

View file

@ -110,7 +110,8 @@ fun List<String>.parseReleaseType() = ReleaseType.parse(this)
* Decodes the genre name from an ID3(v2) constant. See [GENRE_TABLE] for the genre constant map * Decodes the genre name from an ID3(v2) constant. See [GENRE_TABLE] for the genre constant map
* that Auxio uses. * that Auxio uses.
*/ */
fun String.parseId3GenreName() = parseId3v1Genre() ?: parseId3v2Genre() ?: this fun String.parseId3GenreName() =
parseId3v1Genre()?.let { listOf(it) } ?: parseId3v2Genre() ?: listOf(this)
private fun String.parseId3v1Genre(): String? = private fun String.parseId3v1Genre(): String? =
when { when {
@ -126,7 +127,7 @@ private fun String.parseId3v1Genre(): String? =
else -> null else -> null
} }
private fun String.parseId3v2Genre(): String? { private fun String.parseId3v2Genre(): List<String>? {
val groups = (GENRE_RE.matchEntire(this) ?: return null).groupValues val groups = (GENRE_RE.matchEntire(this) ?: return null).groupValues
val genres = mutableSetOf<String>() val genres = mutableSetOf<String>()
@ -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. */ /** Regex that implements matching for ID3v2's genre format. */

View file

@ -50,7 +50,7 @@ import org.oxycblt.auxio.util.logW
* @author OxygenCobalt * @author OxygenCobalt
*/ */
class ExoPlayerBackend(private val inner: MediaStoreBackend) : Indexer.Backend { class ExoPlayerBackend(private val inner: MediaStoreBackend) : Indexer.Backend {
private val runningTasks: Array<Task?> = arrayOfNulls(TASK_CAPACITY) private val taskPool: Array<Task?> = arrayOfNulls(TASK_CAPACITY)
// No need to implement our own query logic, as this backend is still reliant on // No need to implement our own query logic, as this backend is still reliant on
// MediaStore. // 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 // executor and thus will crash the app if an error occurs instead of bubbling
// back up to Indexer. // back up to Indexer.
spin@ while (true) { spin@ while (true) {
for (i in runningTasks.indices) { for (i in taskPool.indices) {
val task = runningTasks[i] val task = taskPool[i]
if (task != null) { if (task != null) {
val song = task.get() val song = task.get()
if (song != null) { if (song != null) {
songs.add(song) songs.add(song)
emitIndexing(Indexer.Indexing.Songs(songs.size, total)) emitIndexing(Indexer.Indexing.Songs(songs.size, total))
runningTasks[i] = Task(context, raw) taskPool[i] = Task(context, raw)
break@spin break@spin
} }
} else { } else {
runningTasks[i] = Task(context, raw) taskPool[i] = Task(context, raw)
break@spin break@spin
} }
} }
@ -99,14 +99,14 @@ class ExoPlayerBackend(private val inner: MediaStoreBackend) : Indexer.Backend {
spin@ while (true) { spin@ while (true) {
// Spin until all of the remaining tasks are complete. // Spin until all of the remaining tasks are complete.
for (i in runningTasks.indices) { for (i in taskPool.indices) {
val task = runningTasks[i] val task = taskPool[i]
if (task != null) { if (task != null) {
val song = task.get() ?: continue@spin val song = task.get() ?: continue@spin
songs.add(song) songs.add(song)
emitIndexing(Indexer.Indexing.Songs(songs.size, total)) 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 } tags["TSO2"]?.let { raw.albumArtistSortName = it }
// Genre, with the weird ID3 rules. // 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) // Release type (GRP1 is sometimes used for this, so fall back to it)
(tags["TXXX:MusicBrainz Album Type"] ?: tags["GRP1"])?.parseReleaseType()?.let { (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() } tags["ALBUMARTISTSORT"]?.let { raw.albumArtistSortName = it.joinToString() }
// Genre, no ID3 rules here // Genre, no ID3 rules here
tags["GENRE"]?.let { raw.genreName = it.joinToString() } tags["GENRE"]?.let { raw.genreNames = it }
// Release type // Release type
tags["RELEASETYPE"]?.parseReleaseType()?.let { raw.albumReleaseType = it } tags["RELEASETYPE"]?.parseReleaseType()?.let { raw.albumReleaseType = it }

View file

@ -332,12 +332,15 @@ class Indexer {
*/ */
private fun buildGenres(songs: List<Song>): List<Genre> { private fun buildGenres(songs: List<Song>): List<Genre> {
val genres = mutableListOf<Genre>() val genres = mutableListOf<Genre>()
val songsByGenre = songs.groupBy { it._rawGenre.groupingId } val songsByGenre = mutableMapOf<Genre.Raw, MutableList<Song>>()
for (song in songs) {
for (rawGenre in song._rawGenres) {
songsByGenre.getOrPut(rawGenre) { mutableListOf() }.add(song)
}
}
for (entry in songsByGenre) { for (entry in songsByGenre) {
// The first song fill suffice for template metadata. genres.add(Genre(entry.key, songs = entry.value))
val templateSong = entry.value[0]
genres.add(Genre(templateSong._rawGenre, songs = entry.value))
} }
logD("Successfully built ${genres.size} genres") logD("Successfully built ${genres.size} genres")

View file

@ -201,7 +201,7 @@ abstract class MediaStoreBackend : Indexer.Backend {
val songId = cursor.getLong(songIdIndex) val songId = cursor.getLong(songIdIndex)
rawSongs rawSongs
.find { it.mediaStoreId == songId } .find { it.mediaStoreId == songId }
?.let { song -> song.genreName = name } ?.let { song -> song.genreNames = name }
if (cursor.position % 50 == 0) { if (cursor.position % 50 == 0) {
// Only check for a cancellation every 50 songs or so (~20ms). // Only check for a cancellation every 50 songs or so (~20ms).

View file

@ -162,7 +162,8 @@ class PlaybackStateManager private constructor() {
PlaybackMode.ALL_SONGS -> null PlaybackMode.ALL_SONGS -> null
PlaybackMode.IN_ALBUM -> song.album PlaybackMode.IN_ALBUM -> song.album
PlaybackMode.IN_ARTIST -> song.album.artist 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) applyNewQueue(library, settings, settings.keepShuffle && isShuffled, song)

View file

@ -160,7 +160,9 @@ class MediaSessionComponent(
.putText(MediaMetadataCompat.METADATA_KEY_AUTHOR, artist) .putText(MediaMetadataCompat.METADATA_KEY_AUTHOR, artist)
.putText(MediaMetadataCompat.METADATA_KEY_COMPOSER, artist) .putText(MediaMetadataCompat.METADATA_KEY_COMPOSER, artist)
.putText(MediaMetadataCompat.METADATA_KEY_WRITER, 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( .putText(
METADATA_KEY_PARENT, METADATA_KEY_PARENT,
parent?.resolveName(context) ?: context.getString(R.string.lbl_all_songs)) parent?.resolveName(context) ?: context.getString(R.string.lbl_all_songs))