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.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<Genre, Int>()
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 =

View file

@ -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<Genre> = 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<Genre>
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<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 }
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
}

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
* 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<String>? {
val groups = (GENRE_RE.matchEntire(this) ?: return null).groupValues
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. */

View file

@ -50,7 +50,7 @@ import org.oxycblt.auxio.util.logW
* @author OxygenCobalt
*/
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
// 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 }

View file

@ -332,12 +332,15 @@ class Indexer {
*/
private fun buildGenres(songs: List<Song>): List<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) {
// 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")

View file

@ -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).

View file

@ -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)

View file

@ -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))