music: add finalization routine

Combine validation into a new finalization routine that now
individually validates and in the future may finalize certain
fields that require linking to be properly created.
This commit is contained in:
Alexander Capehart 2022-09-18 19:57:02 -06:00
parent 765f2f9a18
commit b6d1cd7cb0
No known key found for this signature in database
GPG key ID: 37DBE3621FE9AD47
8 changed files with 105 additions and 72 deletions

View file

@ -3,12 +3,14 @@
## dev ## dev
#### What's New #### What's New
- Added support for songs with multiple genres
- Reworked music hashing to be even more reliable (Will wipe playback state) - Reworked music hashing to be even more reliable (Will wipe playback state)
#### What's Improved #### What's Improved
- Sorting now takes accented characters into account - Sorting now takes accented characters into account
- Added support for compilation sub-release-types like (DJ) Mix - Added support for compilation sub-release-types like (DJ) Mix
- Album dates now start from the earliest date instead of latest date - Album dates now start from the earliest date instead of latest date
- Reshuffling the queue will no longer drop any songs you have added/removed
#### What's Fixed #### What's Fixed
- Fixed issue where the scroll popup would not display correctly in landscape mode [#230] - Fixed issue where the scroll popup would not display correctly in landscape mode [#230]

View file

@ -41,8 +41,6 @@ import java.util.UUID
import kotlin.math.max import kotlin.math.max
import kotlin.math.min import kotlin.math.min
// TODO: Make empty parents a hard error
// --- MUSIC MODELS --- // --- MUSIC MODELS ---
/** [Item] variant that represents a music item. */ /** [Item] variant that represents a music item. */
@ -55,20 +53,31 @@ sealed class Music : Item {
/** The raw sorting name of this item. Null if not present. */ /** The raw sorting name of this item. Null if not present. */
abstract val rawSortName: String? abstract val rawSortName: String?
/**
* A key used by the sorting system that takes into account the sort tags of this item,
* any (english) articles that prefix the names, and collation rules.
*/
abstract val collationKey: CollationKey?
/** /**
* Resolve a name from it's raw form to a form suitable to be shown in a UI. * Resolve a name from it's raw form to a form suitable to be shown in a UI.
* Null values will be resolved into their string form with this function. * Null values will be resolved into their string form with this function.
*/ */
abstract fun resolveName(context: Context): String abstract fun resolveName(context: Context): String
/** // Equality is based on UIDs, as some items (Especially artists) can have identical
* A key used by the sorting system that takes into account the sort tags of this item, // properties (Name) yet non-identical UIDs due to MusicBrainz tags
* any (english) articles that prefix the names, and collation rules.
*/
val collationKey: CollationKey? by lazy {
// Ideally, we would generate this on creation, but this is an abstract class, which
// requires us to generate it lazily instead.
override fun hashCode() = uid.hashCode()
override fun equals(other: Any?) =
other is Music && javaClass == other.javaClass && uid == other.uid
/**
* Workaround to allow for easy collation key generation in the initializer without
* base-class initialization issues or slow lazy initialization.
*/
protected fun makeCollationKeyImpl(): CollationKey? {
val sortName = (rawSortName ?: rawName)?.run { val sortName = (rawSortName ?: rawName)?.run {
when { when {
length > 5 && startsWith("the ", ignoreCase = true) -> substring(4) length > 5 && startsWith("the ", ignoreCase = true) -> substring(4)
@ -78,16 +87,15 @@ sealed class Music : Item {
} }
} }
COLLATOR.getCollationKey(sortName) return COLLATOR.getCollationKey(sortName)
} }
// Equality is based on UIDs, as some items (Especially artists) can have identical /**
// properties (Name) yet non-identical UIDs due to MusicBrainz tags * Called when the library has been linked and validation/construction steps dependent
* on linked items should run. It's also used to do last-step initialization of fields
override fun hashCode() = uid.hashCode() * that require any parent values that would not be present during startup.
*/
override fun equals(other: Any?) = abstract fun _finalize()
other is Music && javaClass == other.javaClass && uid == other.uid
/** /**
* A unique identifier for a piece of music. * A unique identifier for a piece of music.
@ -192,6 +200,10 @@ sealed class Music : Item {
sealed class MusicParent : Music() { sealed class MusicParent : Music() {
/** The songs that this parent owns. */ /** The songs that this parent owns. */
abstract val songs: List<Song> abstract val songs: List<Song>
override fun _finalize() {
check(songs.isNotEmpty()) { "Invalid parent: No songs" }
}
} }
/** /**
@ -218,6 +230,8 @@ class Song constructor(raw: Raw, settings: Settings) : Music() {
override val rawSortName = raw.sortName override val rawSortName = raw.sortName
override val collationKey = makeCollationKeyImpl()
override fun resolveName(context: Context) = rawName override fun resolveName(context: Context) = rawName
/** The track number of this song in it's album.. */ /** The track number of this song in it's album.. */
@ -279,15 +293,6 @@ class Song constructor(raw: Raw, settings: Settings) : Music() {
private val albumArtistSortName = raw.albumArtistSortNames.parseMultiValue(settings) private val albumArtistSortName = raw.albumArtistSortNames.parseMultiValue(settings)
.joinToString().ifEmpty { null } .joinToString().ifEmpty { null }
/**
* The raw artist name for this song in particular. First uses the artist tag, and then falls
* back to the album artist tag (i.e parent artist name). Null if name is unknown.
*/
val individualArtistRawName: String?
// Note: This is a getter since it relies on a parent value that will not be initialized
// yet on creation.
get() = artistName ?: album.artist.rawName
/** /**
* Resolve the artist name for this song in particular. First uses the artist tag, and then * Resolve the artist name for this song in particular. First uses the artist tag, and then
* falls back to the album artist tag (i.e parent artist name) * falls back to the album artist tag (i.e parent artist name)
@ -295,6 +300,14 @@ class Song constructor(raw: Raw, settings: Settings) : Music() {
fun resolveIndividualArtistName(context: Context) = fun resolveIndividualArtistName(context: Context) =
artistName ?: album.artist.resolveName(context) artistName ?: album.artist.resolveName(context)
fun areArtistContentsTheSame(other: Song): Boolean {
if (other.artistName != null && artistName != null) {
return other.artistName == artistName
}
return album.artist.rawName == other.album.artist.rawName
}
private val _genres: MutableList<Genre> = mutableListOf() private val _genres: MutableList<Genre> = mutableListOf()
/** /**
@ -331,9 +344,9 @@ class Song constructor(raw: Raw, settings: Settings) : Music() {
_genres.add(genre) _genres.add(genre)
} }
fun _validate() { override fun _finalize() {
(checkNotNull(_album) { "Malformed song: album is null" })._validate() (checkNotNull(_album) { "Malformed song: Album is null" })
check(_genres.isNotEmpty()) { "Malformed song: genres are empty" } check(_genres.isNotEmpty()) { "Malformed song: No genres" }
} }
class Raw class Raw
@ -381,6 +394,8 @@ class Album constructor(raw: Raw, override val songs: List<Song>) : MusicParent(
override val rawSortName = raw.sortName override val rawSortName = raw.sortName
override val collationKey = makeCollationKeyImpl()
override fun resolveName(context: Context) = rawName override fun resolveName(context: Context) = rawName
/** The earliest date this album was released. */ /** The earliest date this album was released. */
@ -415,7 +430,8 @@ class Album constructor(raw: Raw, override val songs: List<Song>) : MusicParent(
_artist = artist _artist = artist
} }
fun _validate() { override fun _finalize() {
super._finalize()
checkNotNull(_artist) { "Invalid album: Artist is null " } checkNotNull(_artist) { "Invalid album: Artist is null " }
} }
@ -478,6 +494,8 @@ constructor(
override val rawSortName = raw.sortName override val rawSortName = raw.sortName
override val collationKey = makeCollationKeyImpl()
override fun resolveName(context: Context) = rawName ?: context.getString(R.string.def_artist) override fun resolveName(context: Context) = rawName ?: context.getString(R.string.def_artist)
private val _songs = mutableListOf<Song>() private val _songs = mutableListOf<Song>()
@ -525,6 +543,8 @@ class Genre constructor(raw: Raw, override val songs: List<Song>) : MusicParent(
// Sort tags don't make sense on genres // Sort tags don't make sense on genres
override val rawSortName = rawName override val rawSortName = rawName
override val collationKey = makeCollationKeyImpl()
override fun resolveName(context: Context) = rawName ?: context.getString(R.string.def_genre) override fun resolveName(context: Context) = rawName ?: context.getString(R.string.def_genre)
/** The total duration of the songs in this genre, in millis. */ /** The total duration of the songs in this genre, in millis. */

View file

@ -34,7 +34,7 @@ import org.oxycblt.auxio.util.contentResolverSafe
* *
* The only other, memory-efficient option is to create our own hybrid database that leverages both * The only other, memory-efficient option is to create our own hybrid database that leverages both
* a typical DB and a mem-cache, like Vinyl. But why would we do that when I've encountered no real * a typical DB and a mem-cache, like Vinyl. But why would we do that when I've encountered no real
* issues with the current system. * issues with the current system?
* *
* [Library] may not be available at all times, so leveraging [Callback] is recommended. Consumers * [Library] may not be available at all times, so leveraging [Callback] is recommended. Consumers
* should also be aware that [Library] may change while they are running, and design their work * should also be aware that [Library] may change while they are running, and design their work

View file

@ -100,7 +100,7 @@ import java.io.File
* music loading process. * music loading process.
* @author OxygenCobalt * @author OxygenCobalt
*/ */
abstract class MediaStoreLayer(private val context: Context, private val cacheLayer: CacheDatabase) { abstract class MediaStoreExtractor(private val context: Context, private val cacheDatabase: CacheDatabase) {
private var cursor: Cursor? = null private var cursor: Cursor? = null
private var idIndex = -1 private var idIndex = -1
@ -129,7 +129,7 @@ abstract class MediaStoreLayer(private val context: Context, private val cacheLa
logD("Initializing") logD("Initializing")
val start = System.currentTimeMillis() val start = System.currentTimeMillis()
cacheLayer.init() cacheDatabase.init()
val storageManager = context.getSystemServiceCompat(StorageManager::class) val storageManager = context.getSystemServiceCompat(StorageManager::class)
_volumes.addAll(storageManager.storageVolumesCompat) _volumes.addAll(storageManager.storageVolumesCompat)
@ -238,7 +238,7 @@ abstract class MediaStoreLayer(private val context: Context, private val cacheLa
cursor?.close() cursor?.close()
cursor = null cursor = null
cacheLayer.finalize(rawSongs) cacheDatabase.finalize(rawSongs)
} }
/** /**
@ -259,7 +259,7 @@ abstract class MediaStoreLayer(private val context: Context, private val cacheLa
raw.dateAdded = cursor.getLong(dateAddedIndex) raw.dateAdded = cursor.getLong(dateAddedIndex)
raw.dateModified = cursor.getLong(dateAddedIndex) raw.dateModified = cursor.getLong(dateAddedIndex)
if (cacheLayer.maybePopulateCachedRaw(raw)) { if (cacheDatabase.maybePopulateCachedRaw(raw)) {
// We found a valid cache entry, no need to extract metadata. // We found a valid cache entry, no need to extract metadata.
logD("Found cached raw: ${raw.name}") logD("Found cached raw: ${raw.name}")
return true return true
@ -368,12 +368,12 @@ abstract class MediaStoreLayer(private val context: Context, private val cacheLa
// speed, we only want to add redundancy on known issues, not with possible issues. // speed, we only want to add redundancy on known issues, not with possible issues.
/** /**
* A [MediaStoreLayer] that completes the music loading process in a way compatible from * A [MediaStoreExtractor] that completes the music loading process in a way compatible from
* API 21 onwards to API 29. * API 21 onwards to API 29.
* @author OxygenCobalt * @author OxygenCobalt
*/ */
class Api21MediaStoreLayer(context: Context, cacheLayer: CacheDatabase) : class Api21MediaStoreExtractor(context: Context, cacheDatabase: CacheDatabase) :
MediaStoreLayer(context, cacheLayer) { MediaStoreExtractor(context, cacheDatabase) {
private var trackIndex = -1 private var trackIndex = -1
private var dataIndex = -1 private var dataIndex = -1
@ -433,13 +433,13 @@ class Api21MediaStoreLayer(context: Context, cacheLayer: CacheDatabase) :
} }
/** /**
* A [MediaStoreLayer] that selects directories and builds paths using the modern volume fields * A [MediaStoreExtractor] that selects directories and builds paths using the modern volume fields
* available from API 29 onwards. * available from API 29 onwards.
* @author OxygenCobalt * @author OxygenCobalt
*/ */
@RequiresApi(Build.VERSION_CODES.Q) @RequiresApi(Build.VERSION_CODES.Q)
open class BaseApi29MediaStoreLayer(context: Context, cacheLayer: CacheDatabase) : open class BaseApi29MediaStoreExtractor(context: Context, cacheDatabase: CacheDatabase) :
MediaStoreLayer(context, cacheLayer) { MediaStoreExtractor(context, cacheDatabase) {
private var volumeIndex = -1 private var volumeIndex = -1
private var relativePathIndex = -1 private var relativePathIndex = -1
@ -489,13 +489,13 @@ open class BaseApi29MediaStoreLayer(context: Context, cacheLayer: CacheDatabase)
} }
/** /**
* A [MediaStoreLayer] that completes the music loading process in a way compatible with at least * A [MediaStoreExtractor] that completes the music loading process in a way compatible with at least
* API 29. * API 29.
* @author OxygenCobalt * @author OxygenCobalt
*/ */
@RequiresApi(Build.VERSION_CODES.Q) @RequiresApi(Build.VERSION_CODES.Q)
open class Api29MediaStoreLayer(context: Context, cacheLayer: CacheDatabase) : open class Api29MediaStoreExtractor(context: Context, cacheDatabase: CacheDatabase) :
BaseApi29MediaStoreLayer(context, cacheLayer) { BaseApi29MediaStoreExtractor(context, cacheDatabase) {
private var trackIndex = -1 private var trackIndex = -1
override fun init(): Cursor { override fun init(): Cursor {
@ -521,13 +521,13 @@ open class Api29MediaStoreLayer(context: Context, cacheLayer: CacheDatabase) :
} }
/** /**
* A [MediaStoreLayer] that completes the music loading process in a way compatible with at least * A [MediaStoreExtractor] that completes the music loading process in a way compatible with at least
* API 30. * API 30.
* @author OxygenCobalt * @author OxygenCobalt
*/ */
@RequiresApi(Build.VERSION_CODES.R) @RequiresApi(Build.VERSION_CODES.R)
class Api30MediaStoreLayer(context: Context, cacheLayer: CacheDatabase) : class Api30MediaStoreExtractor(context: Context, cacheDatabase: CacheDatabase) :
BaseApi29MediaStoreLayer(context, cacheLayer) { BaseApi29MediaStoreExtractor(context, cacheDatabase) {
private var trackIndex: Int = -1 private var trackIndex: Int = -1
private var discIndex: Int = -1 private var discIndex: Int = -1

View file

@ -43,19 +43,19 @@ import org.oxycblt.auxio.util.logW
* *
* @author OxygenCobalt * @author OxygenCobalt
*/ */
class MetadataLayer(private val context: Context, private val mediaStoreLayer: MediaStoreLayer) { class MetadataExtractor(private val context: Context, private val mediaStoreExtractor: MediaStoreExtractor) {
private val taskPool: Array<Task?> = arrayOfNulls(TASK_CAPACITY) private val taskPool: Array<Task?> = arrayOfNulls(TASK_CAPACITY)
/** Initialize the sub-layers that this layer relies on. */ /** Initialize the sub-layers that this layer relies on. */
fun init() = mediaStoreLayer.init().count fun init() = mediaStoreExtractor.init().count
/** Finalize the sub-layers that this layer relies on. */ /** Finalize the sub-layers that this layer relies on. */
fun finalize(rawSongs: List<Song.Raw>) = mediaStoreLayer.finalize(rawSongs) fun finalize(rawSongs: List<Song.Raw>) = mediaStoreExtractor.finalize(rawSongs)
suspend fun parse(emit: suspend (Song.Raw) -> Unit) { suspend fun parse(emit: suspend (Song.Raw) -> Unit) {
while (true) { while (true) {
val raw = Song.Raw() val raw = Song.Raw()
if (mediaStoreLayer.populateRawSong(raw) ?: break) { if (mediaStoreExtractor.populateRawSong(raw) ?: break) {
// No need to extract metadata that was successfully restored from the cache // No need to extract metadata that was successfully restored from the cache
emit(raw) emit(raw)
continue continue

View file

@ -33,11 +33,11 @@ import org.oxycblt.auxio.music.Genre
import org.oxycblt.auxio.music.MusicStore import org.oxycblt.auxio.music.MusicStore
import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.music.Sort import org.oxycblt.auxio.music.Sort
import org.oxycblt.auxio.music.extractor.Api21MediaStoreLayer import org.oxycblt.auxio.music.extractor.Api21MediaStoreExtractor
import org.oxycblt.auxio.music.extractor.Api29MediaStoreLayer import org.oxycblt.auxio.music.extractor.Api29MediaStoreExtractor
import org.oxycblt.auxio.music.extractor.Api30MediaStoreLayer import org.oxycblt.auxio.music.extractor.Api30MediaStoreExtractor
import org.oxycblt.auxio.music.extractor.CacheDatabase import org.oxycblt.auxio.music.extractor.CacheDatabase
import org.oxycblt.auxio.music.extractor.MetadataLayer import org.oxycblt.auxio.music.extractor.MetadataExtractor
import org.oxycblt.auxio.settings.Settings import org.oxycblt.auxio.settings.Settings
import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.util.logD
import org.oxycblt.auxio.util.logE import org.oxycblt.auxio.util.logE
@ -197,25 +197,25 @@ class Indexer {
private suspend fun indexImpl(context: Context): MusicStore.Library? { private suspend fun indexImpl(context: Context): MusicStore.Library? {
emitIndexing(Indexing.Indeterminate) emitIndexing(Indexing.Indeterminate)
// Create the chain of layers. Each layer builds on the previous layer and // Create the chain of extractors. Each extractor builds on the previous and
// enables version-specific features in order to create the best possible music // enables version-specific features in order to create the best possible music
// experience. This is technically dependency injection. Except it doesn't increase // experience. This is technically dependency injection. Except it doesn't increase
// your compile times by 3x. Isn't that nice. // your compile times by 3x. Isn't that nice.
val cacheLayer = CacheDatabase() val cacheDatabase = CacheDatabase()
val mediaStoreLayer = val mediaStoreExtractor =
when { when {
Build.VERSION.SDK_INT >= Build.VERSION_CODES.R -> Build.VERSION.SDK_INT >= Build.VERSION_CODES.R ->
Api30MediaStoreLayer(context, cacheLayer) Api30MediaStoreExtractor(context, cacheDatabase)
Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q -> Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q ->
Api29MediaStoreLayer(context, cacheLayer) Api29MediaStoreExtractor(context, cacheDatabase)
else -> Api21MediaStoreLayer(context, cacheLayer) else -> Api21MediaStoreExtractor(context, cacheDatabase)
} }
val metadataLayer = MetadataLayer(context, mediaStoreLayer) val metadataExtractor = MetadataExtractor(context, mediaStoreExtractor)
val songs = buildSongs(metadataLayer, Settings(context)) val songs = buildSongs(metadataExtractor, Settings(context))
if (songs.isEmpty()) { if (songs.isEmpty()) {
return null return null
} }
@ -226,9 +226,21 @@ class Indexer {
val artists = buildArtists(albums) val artists = buildArtists(albums)
val genres = buildGenres(songs) val genres = buildGenres(songs)
// Sanity check: Ensure that all songs are linked up to albums/artists/genres. // Make sure we finalize all the items now that they are fully built.
for (song in songs) { for (song in songs) {
song._validate() song._finalize()
}
for (album in albums) {
album._finalize()
}
for (artist in artists) {
artist._finalize()
}
for (genre in genres) {
genre._finalize()
} }
logD("Successfully built library in ${System.currentTimeMillis() - buildStart}ms") logD("Successfully built library in ${System.currentTimeMillis() - buildStart}ms")
@ -237,19 +249,19 @@ class Indexer {
} }
/** /**
* Does the initial query over the song database using [metadataLayer]. The songs returned by * Does the initial query over the song database using [metadataExtractor]. The songs returned by
* this function are **not** well-formed. The companion [buildAlbums], [buildArtists], and * this function are **not** well-formed. The companion [buildAlbums], [buildArtists], and
* [buildGenres] functions must be called with the returned list so that all songs are properly * [buildGenres] functions must be called with the returned list so that all songs are properly
* linked up. * linked up.
*/ */
private suspend fun buildSongs(metadataLayer: MetadataLayer, settings: Settings): List<Song> { private suspend fun buildSongs(metadataExtractor: MetadataExtractor, settings: Settings): List<Song> {
logD("Starting indexing process") logD("Starting indexing process")
val start = System.currentTimeMillis() val start = System.currentTimeMillis()
// Initialize the extractor chain. This also nets us the projected total // Initialize the extractor chain. This also nets us the projected total
// that we can show when loading. // that we can show when loading.
val total = metadataLayer.init() val total = metadataExtractor.init()
yield() yield()
// Note: We use a set here so we can eliminate effective duplicates of // Note: We use a set here so we can eliminate effective duplicates of
@ -257,7 +269,7 @@ class Indexer {
val songs = mutableSetOf<Song>() val songs = mutableSetOf<Song>()
val rawSongs = mutableListOf<Song.Raw>() val rawSongs = mutableListOf<Song.Raw>()
metadataLayer.parse { rawSong -> metadataExtractor.parse { rawSong ->
songs.add(Song(rawSong, settings)) songs.add(Song(rawSong, settings))
rawSongs.add(rawSong) rawSongs.add(rawSong)
@ -266,7 +278,7 @@ class Indexer {
emitIndexing(Indexing.Songs(songs.size, total)) emitIndexing(Indexing.Songs(songs.size, total))
} }
metadataLayer.finalize(rawSongs) metadataExtractor.finalize(rawSongs)
val sorted = Sort(Sort.Mode.ByName, true).songs(songs) val sorted = Sort(Sort.Mode.ByName, true).songs(songs)

View file

@ -144,7 +144,6 @@ class SearchViewModel(application: Application) :
private fun List<Song>.filterSongsBy(value: String) = private fun List<Song>.filterSongsBy(value: String) =
baseFilterBy(value) { baseFilterBy(value) {
logD(it.rawSortName)
it.rawSortName?.contains(value, ignoreCase = true) == true || it.rawSortName?.contains(value, ignoreCase = true) == true ||
it.path.name.contains(value) it.path.name.contains(value)
} }

View file

@ -64,7 +64,7 @@ class SongViewHolder private constructor(private val binding: ItemSongBinding) :
object : SimpleItemCallback<Song>() { object : SimpleItemCallback<Song>() {
override fun areContentsTheSame(oldItem: Song, newItem: Song) = override fun areContentsTheSame(oldItem: Song, newItem: Song) =
oldItem.rawName == newItem.rawName && oldItem.rawName == newItem.rawName &&
oldItem.individualArtistRawName == oldItem.individualArtistRawName oldItem.areArtistContentsTheSame(newItem)
} }
} }
} }