music: new fuzzy grouper

New fuzzy grouper that:
1. Does not eagerly group by MBID unless fully tagged
2. Does not eagerly group by artist by default
This commit is contained in:
Alexander Capehart 2024-11-07 23:25:17 -07:00
parent c2d18b77f6
commit d6e09dcf2a
No known key found for this signature in database
GPG key ID: 37DBE3621FE9AD47
4 changed files with 179 additions and 184 deletions

View file

@ -10,6 +10,8 @@
- Added GitHub/email feedback forms to about page - Added GitHub/email feedback forms to about page
#### What's Improved #### What's Improved
- Album grouping no longer done with artist in mind by default
- MusicBrainz IDs will no longer split albums/artists in less tagged libraries
- M3U playlist file name is now proposed if one cannot be found within the file - M3U playlist file name is now proposed if one cannot be found within the file
- Sorting songs by date now uses songs date first, before the earliest album date - Sorting songs by date now uses songs date first, before the earliest album date
- Added working layouts for small split-screen form factors - Added working layouts for small split-screen form factors

View file

@ -21,12 +21,14 @@ package org.oxycblt.auxio.music.device
import android.content.Context import android.content.Context
import android.net.Uri import android.net.Uri
import android.provider.OpenableColumns import android.provider.OpenableColumns
import java.util.UUID
import javax.inject.Inject import javax.inject.Inject
import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.channels.Channel
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.Genre
import org.oxycblt.auxio.music.Music import org.oxycblt.auxio.music.Music
import org.oxycblt.auxio.music.MusicParent
import org.oxycblt.auxio.music.MusicRepository import org.oxycblt.auxio.music.MusicRepository
import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.music.fs.Path import org.oxycblt.auxio.music.fs.Path
@ -51,10 +53,13 @@ import timber.log.Timber as L
interface DeviceLibrary { interface DeviceLibrary {
/** All [Song]s in this [DeviceLibrary]. */ /** All [Song]s in this [DeviceLibrary]. */
val songs: Collection<Song> val songs: Collection<Song>
/** All [Album]s in this [DeviceLibrary]. */ /** All [Album]s in this [DeviceLibrary]. */
val albums: Collection<Album> val albums: Collection<Album>
/** All [Artist]s in this [DeviceLibrary]. */ /** All [Artist]s in this [DeviceLibrary]. */
val artists: Collection<Artist> val artists: Collection<Artist>
/** All [Genre]s in this [DeviceLibrary]. */ /** All [Genre]s in this [DeviceLibrary]. */
val genres: Collection<Genre> val genres: Collection<Genre>
@ -134,11 +139,9 @@ class DeviceLibraryFactoryImpl @Inject constructor() : DeviceLibrary.Factory {
nameFactory: Name.Known.Factory nameFactory: Name.Known.Factory
): DeviceLibraryImpl { ): DeviceLibraryImpl {
val songGrouping = mutableMapOf<Music.UID, SongImpl>() val songGrouping = mutableMapOf<Music.UID, SongImpl>()
val albumGrouping = mutableMapOf<RawAlbum.Key, Grouping<RawAlbum, SongImpl>>() val albumGrouping = mutableMapOf<String?, MutableMap<UUID?, Grouping<RawAlbum, SongImpl>>>()
val artistGrouping = mutableMapOf<RawArtist.Key, Grouping<RawArtist, Music>>() val artistGrouping = mutableMapOf<String?, MutableMap<UUID?, Grouping<RawArtist, Music>>>()
val genreGrouping = mutableMapOf<RawGenre.Key, Grouping<RawGenre, SongImpl>>() val genreGrouping = mutableMapOf<String?, Grouping<RawGenre, SongImpl>>()
// TODO: Use comparators here
// All music information is grouped as it is indexed by other components. // All music information is grouped as it is indexed by other components.
rawSongs.forEachWithTimeout { rawSong -> rawSongs.forEachWithTimeout { rawSong ->
@ -159,62 +162,22 @@ class DeviceLibraryFactoryImpl @Inject constructor() : DeviceLibrary.Factory {
songGrouping[song.uid] = song songGrouping[song.uid] = song
// Group the new song into an album. // Group the new song into an album.
val albumKey = song.rawAlbum.key appendToMusicBrainzIdTree(song, song.rawAlbum, albumGrouping) { old, new ->
val albumBody = albumGrouping[albumKey] compareSongTracks(old, new)
if (albumBody != null) {
albumBody.music.add(song)
val prioritized = albumBody.raw.src
// Since albums are grouped fuzzily, we pick the song with the earliest track to
// use for album information to ensure consistent metadata and UIDs. Fall back to
// the name otherwise.
val higherPriority =
song.track != null &&
(prioritized.track == null ||
song.track < prioritized.track ||
(song.track == prioritized.track && song.name < prioritized.name))
if (higherPriority) {
albumBody.raw = PrioritizedRaw(song.rawAlbum, song)
}
} else {
// Need to initialize this grouping.
albumGrouping[albumKey] =
Grouping(PrioritizedRaw(song.rawAlbum, song), mutableSetOf(song))
} }
// Group the song into each of it's artists. // Group the song into each of it's artists.
for (rawArtist in song.rawArtists) { for (rawArtist in song.rawArtists) {
val artistKey = rawArtist.key appendToMusicBrainzIdTree(song, rawArtist, artistGrouping) { old, new ->
val artistBody = artistGrouping[artistKey] // Artist information from earlier dates is prioritized, as it is less likely to
if (artistBody != null) { // change with the addition of new tracks. Fall back to the name otherwise.
// Since artists are not guaranteed to have songs, song artist information is check(old is SongImpl) // This should always be the case.
// de-prioritized compared to album artist information. compareSongDates(old, new)
artistBody.music.add(song)
} else {
// Need to initialize this grouping.
artistGrouping[artistKey] =
Grouping(PrioritizedRaw(rawArtist, song), mutableSetOf(song))
} }
} }
// Group the song into each of it's genres. // Group the song into each of it's genres.
for (rawGenre in song.rawGenres) { for (rawGenre in song.rawGenres) {
val genreKey = rawGenre.key appendToNameTree(song, rawGenre, genreGrouping) { old, new -> new.name < old.name }
val genreBody = genreGrouping[genreKey]
if (genreBody != null) {
genreBody.music.add(song)
// Genre information from higher songs in ascending alphabetical order are
// prioritized.
val prioritized = genreBody.raw.src
val higherPriority = song.name < prioritized.name
if (higherPriority) {
genreBody.raw = PrioritizedRaw(rawGenre, song)
}
} else {
// Need to initialize this grouping.
genreGrouping[genreKey] =
Grouping(PrioritizedRaw(rawGenre, song), mutableSetOf(song))
}
} }
processedSongs.sendWithTimeout(rawSong) processedSongs.sendWithTimeout(rawSong)
@ -222,47 +185,154 @@ class DeviceLibraryFactoryImpl @Inject constructor() : DeviceLibrary.Factory {
// Now that all songs are processed, also process albums and group them into their // Now that all songs are processed, also process albums and group them into their
// respective artists. // respective artists.
val albums = albumGrouping.values.mapTo(mutableSetOf()) { AlbumImpl(it, nameFactory) } pruneMusicBrainzIdTree(albumGrouping) { old, new ->
compareSongTracks(old, new)
}
val albums = flattenMusicBrainzIdTree(albumGrouping) { AlbumImpl(it, nameFactory) }
for (album in albums) { for (album in albums) {
for (rawArtist in album.rawArtists) { for (rawArtist in album.rawArtists) {
val key = RawArtist.Key(rawArtist) appendToMusicBrainzIdTree(album, rawArtist, artistGrouping) { old, new ->
val body = artistGrouping[key] when (old) {
if (body != null) {
body.music.add(album)
when (val prioritized = body.raw.src) {
// Immediately replace any songs that initially held the priority position. // Immediately replace any songs that initially held the priority position.
is SongImpl -> body.raw = PrioritizedRaw(rawArtist, album) is SongImpl -> true
is AlbumImpl -> { is AlbumImpl -> {
// Album artist information from earlier dates is prioritized, as it is compareAlbumDates(old, new)
// less likely to change with the addition of new tracks. Fall back to
// the name otherwise.
val prioritize =
album.dates != null &&
(prioritized.dates == null ||
album.dates < prioritized.dates ||
(album.dates == prioritized.dates &&
album.name < prioritized.name))
if (prioritize) {
body.raw = PrioritizedRaw(rawArtist, album)
}
} }
else -> throw IllegalStateException() else -> throw IllegalStateException()
} }
} else {
// Need to initialize this grouping.
artistGrouping[key] =
Grouping(PrioritizedRaw(rawArtist, album), mutableSetOf(album))
} }
} }
} }
// Artists and genres do not need to be grouped and can be processed immediately. // Artists and genres do not need to be grouped and can be processed immediately.
val artists = artistGrouping.values.mapTo(mutableSetOf()) { ArtistImpl(it, nameFactory) } pruneMusicBrainzIdTree(artistGrouping) { old, new ->
val genres = genreGrouping.values.mapTo(mutableSetOf()) { GenreImpl(it, nameFactory) } when {
// Immediately replace any songs that initially held the priority position.
old is SongImpl && new is AlbumImpl -> true
old is AlbumImpl && new is SongImpl -> false
old is SongImpl && new is SongImpl -> {
compareSongDates(old, new)
}
old is AlbumImpl && new is AlbumImpl -> {
compareAlbumDates(old, new)
}
else -> throw IllegalStateException()
}
}
val artists = flattenMusicBrainzIdTree(artistGrouping) { ArtistImpl(it, nameFactory) }
val genres = flattenNameTree(genreGrouping) { GenreImpl(it, nameFactory) }
return DeviceLibraryImpl(songGrouping.values.toSet(), albums, artists, genres) return DeviceLibraryImpl(songGrouping.values.toSet(), albums, artists, genres)
} }
private inline fun <R : NameGroupable, O : Music, N : O> appendToNameTree(
music: N,
raw: R,
tree: MutableMap<String?, Grouping<R, O>>,
prioritize: (old: O, new: N) -> Boolean,
) {
val nameKey = raw.name?.lowercase()
val body = tree[nameKey]
if (body != null) {
body.music.add(music)
if (prioritize(body.raw.src, music)) {
body.raw = PrioritizedRaw(raw, music)
}
} else {
// Need to initialize this grouping.
tree[nameKey] = Grouping(PrioritizedRaw(raw, music), mutableSetOf(music))
}
}
private inline fun <R : NameGroupable, O : Music, P : MusicParent> flattenNameTree(
tree: MutableMap<String?, Grouping<R, O>>,
map: (Grouping<R, O>) -> P
): Set<P> = tree.values.mapTo(mutableSetOf()) { map(it) }
private inline fun <R : MusicBrainzGroupable, O : Music, N : O> appendToMusicBrainzIdTree(
music: N,
raw: R,
tree: MutableMap<String?, MutableMap<UUID?, Grouping<R, O>>>,
prioritize: (old: O, new: N) -> Boolean,
) {
val nameKey = raw.name?.lowercase()
val musicBrainzIdGroups = tree[nameKey]
if (musicBrainzIdGroups != null) {
val body = musicBrainzIdGroups[raw.musicBrainzId]
if (body != null) {
body.music.add(music)
if (prioritize(body.raw.src, music)) {
body.raw = PrioritizedRaw(raw, music)
}
} else {
// Need to initialize this grouping.
musicBrainzIdGroups[raw.musicBrainzId] =
Grouping(PrioritizedRaw(raw, music), mutableSetOf(music))
}
} else {
// Need to initialize this grouping.
tree[nameKey] =
mutableMapOf(
raw.musicBrainzId to Grouping(PrioritizedRaw(raw, music), mutableSetOf(music)))
}
}
private inline fun <R, M : Music> pruneMusicBrainzIdTree(
tree: MutableMap<String?, MutableMap<UUID?, Grouping<R, M>>>,
prioritize: (old: M, new: M) -> Boolean
) {
for ((_, musicBrainzIdGroups) in tree) {
var nullGroup = musicBrainzIdGroups[null]
if (nullGroup == null) {
// Full MusicBrainz ID tagging. Nothing to do.
continue
}
// Only partial MusicBrainz ID tagging. For the sake of basic sanity, just
// collapse all of them into the null group.
// TODO: More advanced heuristics eventually (tm)
musicBrainzIdGroups
.filter { it.key != null }
.forEach {
val (_, group) = it
nullGroup.music.addAll(group.music)
if (prioritize(group.raw.src, nullGroup.raw.src)) {
nullGroup.raw = group.raw
}
musicBrainzIdGroups.remove(it.key)
}
}
}
private inline fun <R, M : Music, T : MusicParent> flattenMusicBrainzIdTree(
tree: MutableMap<String?, MutableMap<UUID?, Grouping<R, M>>>,
map: (Grouping<R, M>) -> T
): Set<T> {
val result = mutableSetOf<T>()
for ((_, musicBrainzIdGroups) in tree) {
for (group in musicBrainzIdGroups.values) {
result += map(group)
}
}
return result
}
private fun compareSongTracks(old: SongImpl, new: SongImpl) =
new.track != null &&
(old.track == null ||
new.track < old.track ||
(new.track == old.track && new.name < old.name))
private fun compareAlbumDates(old: AlbumImpl, new: AlbumImpl) =
new.dates != null &&
(old.dates == null ||
new.dates < old.dates ||
(new.dates == old.dates && new.name < old.name))
private fun compareSongDates(old: SongImpl, new: SongImpl) =
new.date != null &&
(old.date == null ||
new.date < old.date ||
(new.date == old.date && new.name < old.name))
} }
// TODO: Avoid redundant data creation // TODO: Avoid redundant data creation

View file

@ -160,7 +160,14 @@ class SongImpl(
name, name,
artistSortNames.getOrNull(i)) artistSortNames.getOrNull(i))
} }
.distinctBy { it.key } // Some songs have the same artist listed multiple times (sometimes with different
// casing!),
// so we need to deduplicate lest finalization reordering fails.
// Since MBID data can wind up clobbered later in the grouper, we can't really
// use it to deduplicate. That means that a hypothetical track with two artists
// of the same name but different MBIDs will be grouped wrong. That is a bridge
// I will cross when I get to it.
.distinctBy { it.name?.lowercase() }
val albumArtistMusicBrainzIds = separators.split(rawSong.albumArtistMusicBrainzIds) val albumArtistMusicBrainzIds = separators.split(rawSong.albumArtistMusicBrainzIds)
val albumArtistNames = separators.split(rawSong.albumArtistNames) val albumArtistNames = separators.split(rawSong.albumArtistNames)
@ -173,7 +180,7 @@ class SongImpl(
name, name,
albumArtistSortNames.getOrNull(i)) albumArtistSortNames.getOrNull(i))
} }
.distinctBy { it.key } .distinctBy { it.name?.lowercase() }
rawAlbum = rawAlbum =
RawAlbum( RawAlbum(
@ -199,7 +206,10 @@ class SongImpl(
val genreNames = val genreNames =
(rawSong.genreNames.parseId3GenreNames() ?: separators.split(rawSong.genreNames)) (rawSong.genreNames.parseId3GenreNames() ?: separators.split(rawSong.genreNames))
rawGenres = rawGenres =
genreNames.map { RawGenre(it) }.distinctBy { it.key }.ifEmpty { listOf(RawGenre()) } genreNames
.map { RawGenre(it) }
.distinctBy { it.name?.lowercase() }
.ifEmpty { listOf(RawGenre()) }
hashCode = 31 * hashCode + rawSong.hashCode() hashCode = 31 * hashCode + rawSong.hashCode()
hashCode = 31 * hashCode + nameFactory.hashCode() hashCode = 31 * hashCode + nameFactory.hashCode()
@ -507,7 +517,7 @@ class ArtistImpl(
* @return The index of the [Artist]'s [RawArtist] within the list. * @return The index of the [Artist]'s [RawArtist] within the list.
*/ */
fun getOriginalPositionIn(rawArtists: List<RawArtist>) = fun getOriginalPositionIn(rawArtists: List<RawArtist>) =
rawArtists.indexOfFirst { it.key == rawArtist.key } rawArtists.indexOfFirst { it.name?.lowercase() == rawArtist.name?.lowercase() }
/** /**
* Perform final validation and organization on this instance. * Perform final validation and organization on this instance.
@ -600,7 +610,7 @@ class GenreImpl(
* @return The index of the [Genre]'s [RawGenre] within the list. * @return The index of the [Genre]'s [RawGenre] within the list.
*/ */
fun getOriginalPositionIn(rawGenres: List<RawGenre>) = fun getOriginalPositionIn(rawGenres: List<RawGenre>) =
rawGenres.indexOfFirst { it.key == rawGenre.key } rawGenres.indexOfFirst { it.name?.lowercase() == rawGenre.name?.lowercase() }
/** /**
* Perform final validation and organization on this instance. * Perform final validation and organization on this instance.

View file

@ -107,49 +107,16 @@ data class RawAlbum(
*/ */
val mediaStoreId: Long, val mediaStoreId: Long,
/** @see Music.uid */ /** @see Music.uid */
val musicBrainzId: UUID?, override val musicBrainzId: UUID?,
/** @see Music.name */ /** @see Music.name */
val name: String, override val name: String,
/** @see Music.name */ /** @see Music.name */
val sortName: String?, val sortName: String?,
/** @see Album.releaseType */ /** @see Album.releaseType */
val releaseType: ReleaseType?, val releaseType: ReleaseType?,
/** @see RawArtist.name */ /** @see RawArtist.name */
val rawArtists: List<RawArtist> val rawArtists: List<RawArtist>
) { ) : MusicBrainzGroupable
val key = Key(this)
/**
* Allows [RawAlbum]s to be compared by "fundamental" information that is unlikely to change on
* an item-by-item
*/
data class Key(private val inner: 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").
private val artistKeys = inner.rawArtists.map { it.key }
// Cache the hash-code for HashMap efficiency.
private val hashCode =
inner.musicBrainzId?.hashCode()
?: (31 * inner.name.lowercase().hashCode() + artistKeys.hashCode())
override fun hashCode() = hashCode
override fun equals(other: Any?) =
other is Key &&
when {
inner.musicBrainzId != null && other.inner.musicBrainzId != null ->
inner.musicBrainzId == other.inner.musicBrainzId
inner.musicBrainzId == null && other.inner.musicBrainzId == null ->
inner.name.equals(other.inner.name, true) && artistKeys == other.artistKeys
else -> false
}
}
}
/** /**
* Raw information about an [ArtistImpl] obtained from the component [SongImpl] and [AlbumImpl] * Raw information about an [ArtistImpl] obtained from the component [SongImpl] and [AlbumImpl]
@ -159,49 +126,12 @@ data class RawAlbum(
*/ */
data class RawArtist( data class RawArtist(
/** @see Music.UID */ /** @see Music.UID */
val musicBrainzId: UUID? = null, override val musicBrainzId: UUID? = null,
/** @see Music.name */ /** @see Music.name */
val name: String? = null, override val name: String? = null,
/** @see Music.name */ /** @see Music.name */
val sortName: String? = null val sortName: String? = null
) { ) : MusicBrainzGroupable
val key = Key(this)
/**
* Allows [RawArtist]s to be compared by "fundamental" information that is unlikely to change on
* an item-by-item
*/
data class Key(private val inner: 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.
// Cache the hashCode for HashMap efficiency.
val hashCode = inner.musicBrainzId?.hashCode() ?: inner.name?.lowercase().hashCode()
// Compare names and MusicBrainz IDs in order to differentiate artists with the
// same name in large libraries.
override fun hashCode() = hashCode
override fun equals(other: Any?) =
other is Key &&
when {
inner.musicBrainzId != null && other.inner.musicBrainzId != null ->
inner.musicBrainzId == other.inner.musicBrainzId
inner.musicBrainzId == null && other.inner.musicBrainzId == null ->
when {
inner.name != null && other.inner.name != null ->
inner.name.equals(other.inner.name, true)
inner.name == null && other.inner.name == null -> true
else -> false
}
else -> false
}
}
}
/** /**
* Raw information about a [GenreImpl] obtained from the component [SongImpl] instances. * Raw information about a [GenreImpl] obtained from the component [SongImpl] instances.
@ -210,32 +140,15 @@ data class RawArtist(
*/ */
data class RawGenre( data class RawGenre(
/** @see Music.name */ /** @see Music.name */
val name: String? = null override val name: String? = null
) { ) : NameGroupable
val key = Key(this)
/** interface NameGroupable {
* Allows [RawGenre]s to be compared by "fundamental" information that is unlikely to change on val name: String?
* an item-by-item }
*/
data class Key(private val inner: RawGenre) {
// Cache the hashCode for HashMap efficiency.
private val hashCode = inner.name?.lowercase().hashCode()
// Only group by the lowercase genre name. This allows Genre grouping to be interface MusicBrainzGroupable : NameGroupable {
// case-insensitive, which may be helpful in some libraries with different ways of val musicBrainzId: UUID?
// formatting genres.
override fun hashCode() = hashCode
override fun equals(other: Any?) =
other is Key &&
when {
inner.name != null && other.inner.name != null ->
inner.name.equals(other.inner.name, true)
inner.name == null && other.inner.name == null -> true
else -> false
}
}
} }
/** /**