Skip to content

Commit

Permalink
music: add musicbrainz id support
Browse files Browse the repository at this point in the history
Add support for MusicBrainz IDs (MBIDs) in both grouping and UID
creation.

This should help with very large libraries where artist names
collide, thus requiring differentiation through other means. It also
theoretically opens the door to fetch online metadata, however I don't
really care for that and it would violate the non-connectivity promise
of Auxio.

Resolves #202.
  • Loading branch information
OxygenCobalt committed Sep 23, 2022
1 parent 5c76838 commit b58fce9
Show file tree
Hide file tree
Showing 4 changed files with 100 additions and 58 deletions.
7 changes: 4 additions & 3 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,11 @@

#### What's New
- Massively reworked music loading system:
- Auxio now supports multiple artists
- Auxio now supports multiple genres
- Artists and album artists are now both given equal importance in the UI
- Added support for multiple artists
- Added support for multiple genres
- Artists and album artists are now both given UI entires
- Made music hashing rely on the more reliable MD5
- Added support for MusicBrainz IDs (MBIDs)
- **This may impact your library.** Instructions on how to update your library to result in a good
artist experience will be added to the FAQ.

Expand Down
102 changes: 65 additions & 37 deletions app/src/main/java/org/oxycblt/auxio/music/Music.kt
Original file line number Diff line number Diff line change
Expand Up @@ -160,17 +160,15 @@ sealed class Music : Item {
}

val mode = MusicMode.fromInt(ids[0].toIntOrNull(16) ?: return null) ?: return null
val uuid = UUID.fromString(ids[1])
val uuid = ids[1].toUuidOrNull() ?: return null

return UID(format, mode, uuid)
}

/**
* Make a UUID derived from the MD5 hash of the data digested in [updates].
*
* This is Auxio's UID format.
*/
fun hashed(mode: MusicMode, updates: MessageDigest.() -> Unit): UID {
fun auxio(mode: MusicMode, updates: MessageDigest.() -> Unit): UID {
// Auxio hashes consist of the MD5 hash of the non-subjective, consistent
// tags in a music item. For easier use with MusicBrainz IDs, we transform
// this into a UUID too.
Expand All @@ -179,6 +177,12 @@ sealed class Music : Item {
val uuid = digest.digest().toUuid()
return UID(Format.AUXIO, mode, uuid)
}

/**
* Make a UUID derived from a MusicBrainz ID.
*/
fun musicBrainz(mode: MusicMode, uuid: UUID): UID =
UID(Format.MUSICBRAINZ, mode, uuid)
}
}

Expand All @@ -203,7 +207,7 @@ sealed class MusicParent : Music() {
* @author OxygenCobalt
*/
class Song constructor(raw: Raw, settings: Settings) : Music() {
override val uid = UID.hashed(MusicMode.SONGS) {
override val uid = raw.musicBrainzId?.toUuidOrNull()?.let { UID.musicBrainz(MusicMode.SONGS, it) } ?: UID.auxio(MusicMode.SONGS) {
// Song UIDs are based on the raw data without parsing so that they remain
// consistent across music setting changes. Parents are not held up to the
// same standard since grouping is already inherently linked to settings.
Expand Down Expand Up @@ -273,20 +277,24 @@ class Song constructor(raw: Raw, settings: Settings) : Music() {
val album: Album
get() = unlikelyToBeNull(_album)

private val artistNames = raw.artistNames.parseMultiValue(settings)
private val artistMusicBrainzIds = raw.artistMusicBrainzIds.parseMultiValue(settings)

private val albumArtistNames = raw.albumArtistNames.parseMultiValue(settings)
private val artistNames = raw.artistNames.parseMultiValue(settings)

private val artistSortNames = raw.artistSortNames.parseMultiValue(settings)

private val albumArtistMusicBrainzIds = raw.albumArtistMusicBrainzIds.parseMultiValue(settings)

private val albumArtistNames = raw.albumArtistNames.parseMultiValue(settings)

private val albumArtistSortNames = raw.albumArtistSortNames.parseMultiValue(settings)

private val rawArtists = artistNames.mapIndexed { i, name ->
Artist.Raw(name, artistSortNames.getOrNull(i))
Artist.Raw(artistMusicBrainzIds.getOrNull(i)?.toUuidOrNull(), name, artistSortNames.getOrNull(i))
}

private val rawAlbumArtists = albumArtistNames.mapIndexed { i, name ->
Artist.Raw(name, albumArtistSortNames.getOrNull(i))
Artist.Raw(albumArtistMusicBrainzIds.getOrNull(i)?.toUuidOrNull(), name, albumArtistSortNames.getOrNull(i))
}

private val _artists = mutableListOf<Artist>()
Expand Down Expand Up @@ -339,15 +347,16 @@ class Song constructor(raw: Raw, settings: Settings) : Music() {
// --- INTERNAL FIELDS ---

val _rawGenres = raw.genreNames.parseId3GenreNames(settings)
.map { Genre.Raw(it) }.ifEmpty { listOf(Genre.Raw(null)) }
.map { Genre.Raw(it) }.ifEmpty { listOf(Genre.Raw()) }

val _rawArtists = rawArtists.ifEmpty { rawAlbumArtists }.ifEmpty {
listOf(Artist.Raw(null, null))
listOf(Artist.Raw())
}

val _rawAlbum =
Album.Raw(
mediaStoreId = requireNotNull(raw.albumMediaStoreId) { "Invalid raw: No album id" },
musicBrainzId = raw.albumMusicBrainzId?.toUuidOrNull(),
name = requireNotNull(raw.albumName) { "Invalid raw: No album name" },
sortName = raw.albumSortName,
releaseType = raw.albumReleaseType.parseReleaseType(settings),
Expand Down Expand Up @@ -377,7 +386,7 @@ class Song constructor(raw: Raw, settings: Settings) : Music() {
class Raw
constructor(
var mediaStoreId: Long? = null,
var mbid: UUID? = null,
var musicBrainzId: String? = null,
var name: String? = null,
var sortName: String? = null,
var displayName: String? = null,
Expand All @@ -392,14 +401,14 @@ class Song constructor(raw: Raw, settings: Settings) : Music() {
var disc: Int? = null,
var date: Date? = null,
var albumMediaStoreId: Long? = null,
var albumMbid: UUID? = null,
var albumMusicBrainzId: String? = null,
var albumName: String? = null,
var albumSortName: String? = null,
var albumReleaseType: List<String> = listOf(),
var artistMbids: List<UUID> = listOf(),
var artistMusicBrainzIds: List<String> = listOf(),
var artistNames: List<String> = listOf(),
var artistSortNames: List<String> = listOf(),
var albumArtistMbids: List<UUID> = listOf(),
var albumArtistMusicBrainzIds: List<String> = listOf(),
var albumArtistNames: List<String> = listOf(),
var albumArtistSortNames: List<String> = listOf(),
var genreNames: List<String> = listOf()
Expand All @@ -411,13 +420,14 @@ class Song constructor(raw: Raw, settings: Settings) : Music() {
* @author OxygenCobalt
*/
class Album constructor(raw: Raw, override val songs: List<Song>) : MusicParent() {
override val uid = UID.hashed(MusicMode.ALBUMS) {
// Hash based on only names despite the presence of a date to increase stability.
// I don't know if there is any situation where an artist will have two albums with
// the exact same name, but if there is, I would love to know.
update(raw.name)
update(raw.rawArtists.map { it.name })
}
override val uid = raw.musicBrainzId?.let { UID.musicBrainz(MusicMode.ALBUMS, it) }
?: UID.auxio(MusicMode.ALBUMS) {
// Hash based on only names despite the presence of a date to increase stability.
// I don't know if there is any situation where an artist will have two albums with
// the exact same name, but if there is, I would love to know.
update(raw.name)
update(raw.rawArtists.map { it.name })
}

override val rawName = raw.name

Expand Down Expand Up @@ -517,17 +527,27 @@ class Album constructor(raw: Raw, override val songs: List<Song>) : MusicParent(

class Raw(
val mediaStoreId: Long,
val musicBrainzId: UUID?,
val name: String,
val sortName: String?,
val releaseType: ReleaseType?,
val rawArtists: List<Artist.Raw>
) {
private val hashCode = 31 * name.lowercase().hashCode() + rawArtists.hashCode()
private val hashCode =
musicBrainzId?.hashCode() ?: (31 * name.lowercase().hashCode() + rawArtists.hashCode())

override fun hashCode() = hashCode

override fun equals(other: Any?) =
other is Raw && name.equals(other.name, true) && rawArtists == other.rawArtists
override fun equals(other: Any?): Boolean {
if (other !is Raw) return false
if (musicBrainzId != null && other.musicBrainzId != null &&
musicBrainzId == other.musicBrainzId
) {
return true
}

return name.equals(other.name, true) && rawArtists == other.rawArtists
}
}
}

Expand All @@ -538,7 +558,7 @@ class Album constructor(raw: Raw, override val songs: List<Song>) : MusicParent(
*/
class Artist
constructor(raw: Raw, songAlbums: List<Music>) : MusicParent() {
override val uid = UID.hashed(MusicMode.ARTISTS) { update(raw.name) }
override val uid = raw.musicBrainzId?.let { UID.musicBrainz(MusicMode.ARTISTS, it) } ?: UID.auxio(MusicMode.ARTISTS) { update(raw.name) }

override val rawName = raw.name

Expand Down Expand Up @@ -615,18 +635,26 @@ constructor(raw: Raw, songAlbums: List<Music>) : MusicParent() {
.sortedByDescending { genre -> songs.count { it.genres.contains(genre) } }
}

class Raw(val name: String?, val sortName: String?) {
private val hashCode = name?.lowercase().hashCode()
class Raw(val musicBrainzId: UUID? = null, val name: String? = null, val sortName: String? = null) {
private val hashCode = musicBrainzId?.hashCode() ?: name?.lowercase().hashCode()

override fun hashCode() = hashCode

override fun equals(other: Any?) =
other is Raw &&
when {
name != null && other.name != null -> name.equals(other.name, true)
name == null && other.name == null -> true
else -> false
}
override fun equals(other: Any?): Boolean {
if (other !is Raw) return false

if (musicBrainzId != null && other.musicBrainzId != null &&
musicBrainzId == other.musicBrainzId
) {
return true
}

return when {
name != null && other.name != null -> name.equals(other.name, true)
name == null && other.name == null -> true
else -> false
}
}
}
}

Expand All @@ -635,7 +663,7 @@ constructor(raw: Raw, songAlbums: List<Music>) : MusicParent() {
* @author OxygenCobalt
*/
class Genre constructor(raw: Raw, override val songs: List<Song>) : MusicParent() {
override val uid = UID.hashed(MusicMode.GENRES) { update(raw.name) }
override val uid = UID.auxio(MusicMode.GENRES) { update(raw.name) }

override val rawName = raw.name

Expand Down Expand Up @@ -674,7 +702,7 @@ class Genre constructor(raw: Raw, override val songs: List<Song>) : MusicParent(
check(songs.isNotEmpty()) { "Malformed genre: Empty" }
}

class Raw(val name: String?) {
class Raw(val name: String? = null) {
private val hashCode = name?.lowercase().hashCode()

override fun hashCode() = hashCode
Expand Down
7 changes: 7 additions & 0 deletions app/src/main/java/org/oxycblt/auxio/music/MusicUtil.kt
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import android.provider.MediaStore
import android.text.format.DateUtils
import org.oxycblt.auxio.R
import org.oxycblt.auxio.util.logD
import java.util.UUID

/** Shortcut for making a [ContentResolver] query with less superfluous arguments. */
fun ContentResolver.queryCursor(
Expand Down Expand Up @@ -58,6 +59,12 @@ val Long.audioUri: Uri
val Long.albumCoverUri: Uri
get() = ContentUris.withAppendedId(EXTERNAL_ALBUM_ART_URI, this)

fun String.toUuidOrNull(): UUID? = try {
UUID.fromString(this)
} catch (e: IllegalArgumentException) {
null
}

/** Shortcut to resolve a year from a nullable date. Will return "No Date" if it is null. */
fun Date?.resolveYear(context: Context) =
this?.resolveYear(context) ?: context.getString(R.string.def_date)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@ import org.oxycblt.auxio.util.logW
* pitfalls given ExoPlayer's cozy relationship with native code. However, this backend should do
* enough to eliminate such issues.
*
* TODO: Fix failing ID3v2 multi-value tests in fork (Implies parsing problem)
*
* @author OxygenCobalt
*/
class MetadataExtractor(private val context: Context, private val mediaStoreExtractor: MediaStoreExtractor) {
Expand Down Expand Up @@ -193,7 +195,8 @@ class Task(context: Context, private val raw: Song.Raw) {
}

private fun populateId3v2(tags: Map<String, List<String>>) {
// (Sort) Title
// Song
tags["TXXX:MusicBrainz Release Track Id"]?.let { raw.musicBrainzId = it[0] }
tags["TIT2"]?.let { raw.name = it[0] }
tags["TSOT"]?.let { raw.sortName = it[0] }

Expand All @@ -219,25 +222,26 @@ class Task(context: Context, private val raw: Song.Raw) {
)
?.let { raw.date = it }

// (Sort) Album
// Album
tags["TXXX:MusicBrainz Album Id"]?.let { raw.albumMusicBrainzId = it[0] }
tags["TALB"]?.let { raw.albumName = it[0] }
tags["TSOA"]?.let { raw.albumSortName = it[0] }
(tags["TXXX:MusicBrainz Album Type"] ?: tags["GRP1"])?.let {
raw.albumReleaseType = it
}

// (Sort) Artist
// Artist
tags["TXXX:MusicBrainz Artist Id"]?.let { raw.artistMusicBrainzIds = it }
(tags["TXXX:ARTISTS"] ?: tags["TPE1"])?.let { raw.artistNames = it }
tags["TSOP"]?.let { raw.artistSortNames = it }

// (Sort) Album artist
// Album artist
tags["TXXX:MusicBrainz Album Artist Id"]?.let { raw.albumArtistMusicBrainzIds = it }
tags["TPE2"]?.let { raw.albumArtistNames = it }
tags["TSO2"]?.let { raw.albumArtistSortNames = it }

// Genre, with the weird ID3 rules.
// Genre
tags["TCON"]?.let { raw.genreNames = it }

// Release type (GRP1 is sometimes used for this, so fall back to it)
(tags["TXXX:MusicBrainz Album Type"] ?: tags["GRP1"])?.let {
raw.albumReleaseType = it
}
}

private fun parseId3v23Date(tags: Map<String, List<String>>): Date? {
Expand Down Expand Up @@ -267,7 +271,8 @@ class Task(context: Context, private val raw: Song.Raw) {
}

private fun populateVorbis(tags: Map<String, List<String>>) {
// (Sort) Title
// Song
tags["MUSICBRAINZ_RELEASETRACKID"]?.let { raw.musicBrainzId = it[0] }
tags["TITLE"]?.let { raw.name = it[0] }
tags["TITLESORT"]?.let { raw.sortName = it[0] }

Expand All @@ -290,23 +295,24 @@ class Task(context: Context, private val raw: Song.Raw) {
)
?.let { raw.date = it }

// (Sort) Album
// Album
tags["MUSICBRAINZ_ALBUMID"]?.let { raw.albumMusicBrainzId = it[0] }
tags["ALBUM"]?.let { raw.albumName = it[0] }
tags["ALBUMSORT"]?.let { raw.albumSortName = it[0] }
tags["RELEASETYPE"]?.let { raw.albumReleaseType = it }

// (Sort) Artist
// Artist
tags["MUSICBRAINZ_ARTISTID"]?.let { raw.artistMusicBrainzIds = it }
tags["ARTIST"]?.let { raw.artistNames = it }
tags["ARTISTSORT"]?.let { raw.artistSortNames = it }

// (Sort) Album artist
// Album artist
tags["MUSICBRAINZ_ALBUMARTISTID"]?.let { raw.albumArtistMusicBrainzIds = it }
tags["ALBUMARTIST"]?.let { raw.albumArtistNames = it }
tags["ALBUMARTISTSORT"]?.let { raw.albumArtistSortNames = it }

// Genre, no ID3 rules here
// Genre
tags["GENRE"]?.let { raw.genreNames = it }

// Release type
tags["RELEASETYPE"]?.let { raw.albumReleaseType = it }
}

/**
Expand Down

0 comments on commit b58fce9

Please sign in to comment.