music: restructure around raw objects

Restructure music data around a series of "raw" objects that better
hide the internal information needed to properly construct the library.
This commit is contained in:
Alexander Capehart 2022-09-03 14:28:06 -06:00
parent c9422b7f9d
commit 022f92f27f
No known key found for this signature in database
GPG key ID: 37DBE3621FE9AD47
5 changed files with 263 additions and 283 deletions

View file

@ -20,7 +20,6 @@
package org.oxycblt.auxio.music package org.oxycblt.auxio.music
import android.content.Context import android.content.Context
import android.net.Uri
import kotlin.math.max import kotlin.math.max
import kotlin.math.min import kotlin.math.min
import org.oxycblt.auxio.BuildConfig import org.oxycblt.auxio.BuildConfig
@ -64,47 +63,11 @@ sealed class MusicParent : Music() {
abstract val songs: List<Song> abstract val songs: List<Song>
} }
/** The data object for a song. */ /**
data class Song( * A song.
override val rawName: String, * @author OxygenCobalt
override val rawSortName: String?, */
/** The path of this song. */ data class Song(private val raw: Raw) : Music() {
val path: Path,
/** The URI linking to this song's file. */
val uri: Uri,
/** The mime type of this song. */
val mimeType: MimeType,
/** The size of this song (in bytes) */
val size: Long,
/** The datetime at which this media item was added, represented as a unix timestamp. */
val dateAdded: Long,
/** The total duration of this song, in millis. */
val durationMs: Long,
/** The track number of this song, null if there isn't any. */
val track: Int?,
/** The disc number of this song, null if there isn't any. */
val disc: Int?,
/** Internal field. Do not use. */
val _date: Date?,
/** Internal field. Do not use. */
val _albumName: String,
/** Internal field. Do not use. */
val _albumSortName: String?,
/** Internal field. Do not use. */
val _albumReleaseType: ReleaseType?,
/** Internal field. Do not use. */
val _albumCoverUri: Uri,
/** Internal field. Do not use. */
val _artistName: String?,
/** Internal field. Do not use. */
val _artistSortName: String?,
/** Internal field. Do not use. */
val _albumArtistName: String?,
/** Internal field. Do not use. */
val _albumArtistSortName: String?,
/** Internal field. Do not use. */
val _genreName: String?
) : Music() {
override val id: Long override val id: Long
get() { get() {
var result = rawName.toMusicId() var result = rawName.toMusicId()
@ -116,8 +79,45 @@ data class Song(
return result return result
} }
override val rawName = requireNotNull(raw.name) { "Invalid raw: No title" }
override val rawSortName = raw.sortName
override fun resolveName(context: Context) = rawName override fun resolveName(context: Context) = rawName
/** The URI pointing towards this audio file. */
val uri = requireNotNull(raw.mediaStoreId) { "Invalid raw: No id" }.audioUri
/**
* The path component of the audio file for this music. Only intended for display. Use [uri] to
* open the audio file.
*/
val path =
Path(
name = requireNotNull(raw.displayName) { "Invalid raw: No display name" },
parent = requireNotNull(raw.directory) { "Invalid raw: No parent directory" })
/** The mime type of the audio file. Only intended for display. */
val mimeType =
MimeType(
fromExtension = requireNotNull(raw.extensionMimeType) { "Invalid raw: No mime type" },
fromFormat = raw.formatMimeType)
/** The size of this audio file. */
val size = requireNotNull(raw.size) { "Invalid raw: No size" }
/** The duration of this audio file, in millis. */
val durationMs = requireNotNull(raw.durationMs) { "Invalid raw: No duration" }
/** The date this audio file was added, as a unix epoch timestamp. */
val dateAdded = requireNotNull(raw.dateAdded) { "Invalid raw: No date added" }
/** The track number of this song in it's album.. */
val track = raw.track
/** The disc number of this song in it's album. */
val disc = raw.disc
private var _album: Album? = null private var _album: Album? = null
/** The album of this song. */ /** The album of this song. */
val album: Album val album: Album
@ -133,78 +133,99 @@ data class Song(
* back to the album artist tag (i.e parent artist name). Null if name is unknown. * back to the album artist tag (i.e parent artist name). Null if name is unknown.
*/ */
val individualArtistRawName: String? val individualArtistRawName: String?
get() = _artistName ?: album.artist.rawName get() = raw.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)
*/ */
fun resolveIndividualArtistName(context: Context) = fun resolveIndividualArtistName(context: Context) =
_artistName ?: album.artist.resolveName(context) raw.artistName ?: album.artist.resolveName(context)
/** Internal field. Do not use. */ // --- INTERNAL FIELDS ---
val _albumGroupingId: Long
get() {
var result = _artistGroupingName.toMusicId()
result = 31 * result + _albumName.toMusicId()
return result
}
/** Internal field. Do not use. */ val _distinct =
val _genreGroupingId: Long rawName to
get() = _genreName.toMusicId() raw.albumName to
raw.artistName to
raw.albumArtistName to
raw.genreName to
track to
disc to
durationMs
/** Internal field. Do not use. */ val _rawAlbum: Album.Raw
val _artistGroupingName: String?
get() = _albumArtistName ?: _artistName
/** Internal field. Do not use. */ val _rawGenre = Genre.Raw(raw.genreName)
val _artistGroupingSortName: String?
get() =
when {
_albumArtistName != null -> _albumArtistSortName
_artistName != null -> _artistSortName
else -> null
}
/** Internal field. Do not use. */
val _isMissingAlbum: Boolean val _isMissingAlbum: Boolean
get() = _album == null get() = _album == null
/** Internal field. Do not use. */
val _isMissingArtist: Boolean val _isMissingArtist: Boolean
get() = _album?._isMissingArtist ?: true get() = _album?._isMissingArtist ?: true
/** Internal field. Do not use. */
val _isMissingGenre: Boolean val _isMissingGenre: Boolean
get() = _genre == null get() = _genre == null
/** Internal method. Do not use. */
fun _link(album: Album) { fun _link(album: Album) {
_album = album _album = album
} }
/** Internal method. Do not use. */
fun _link(genre: Genre) { fun _link(genre: Genre) {
_genre = genre _genre = genre
} }
init {
val artistName: String?
val artistSortName: String?
if (raw.albumArtistName != null) {
artistName = raw.albumArtistName
artistSortName = raw.albumArtistSortName
} else {
artistName = raw.artistName
artistSortName = raw.artistSortName
}
_rawAlbum =
Album.Raw(
mediaStoreId = raw.albumMediaStoreId,
name = raw.albumName,
sortName = raw.albumSortName,
date = raw.date,
releaseType = raw.albumReleaseType,
artistName,
artistSortName)
}
data class Raw(
var mediaStoreId: Long? = null,
var name: String? = null,
var sortName: String? = null,
var displayName: String? = null,
var directory: Directory? = null,
var extensionMimeType: String? = null,
var formatMimeType: String? = null,
var size: Long? = null,
var dateAdded: Long? = null,
var durationMs: Long? = null,
var track: Int? = null,
var disc: Int? = null,
var date: Date? = null,
var albumMediaStoreId: Long? = null,
var albumName: String? = null,
var albumSortName: String? = null,
var albumReleaseType: ReleaseType? = null,
var artistName: String? = null,
var artistSortName: String? = null,
var albumArtistName: String? = null,
var albumArtistSortName: String? = null,
var genreName: String? = null
)
} }
/** The data object for an album. */ /** The data object for an album. */
data class Album( data class Album(private val raw: Raw, override val songs: List<Song>) : MusicParent() {
override val rawName: String,
override val rawSortName: String?,
/** The date this album was released. */
val date: Date?,
/** The type of release this album has. */
val releaseType: ReleaseType,
/** The URI for the cover image corresponding to this album. */
val coverUri: Uri,
/** The songs of this album. */
override val songs: List<Song>,
/** Internal field. Do not use. */
val _artistGroupingName: String?,
/** Internal field. Do not use. */
val _artistGroupingSortName: String?
) : MusicParent() {
init { init {
for (song in songs) { for (song in songs) {
song._link(this) song._link(this)
@ -219,8 +240,24 @@ data class Album(
return result return result
} }
override val rawName = requireNotNull(raw.name) { "Invalid raw: No name" }
override val rawSortName = raw.sortName
override fun resolveName(context: Context) = rawName override fun resolveName(context: Context) = rawName
/**
* The album cover URI for this album. Usually low quality, so using Coil is recommended
* instead.
*/
val coverUri = requireNotNull(raw.mediaStoreId) { "Invalid raw: No id" }.albumCoverUri
/** The latest date this album was released. */
val date = raw.date
/** The release type of this album, such as "EP". Defaults to "Album". */
val releaseType = raw.releaseType ?: ReleaseType.Album(null)
private var _artist: Artist? = null private var _artist: Artist? = null
/** The parent artist of this album. */ /** The parent artist of this album. */
val artist: Artist val artist: Artist
@ -229,21 +266,38 @@ data class Album(
/** The earliest date a song in this album was added. */ /** The earliest date a song in this album was added. */
val dateAdded = songs.minOf { it.dateAdded } val dateAdded = songs.minOf { it.dateAdded }
val durationMs: Long /** The total duration of songs in this album, in millis. */
get() = songs.sumOf { it.durationMs } val durationMs = songs.sumOf { it.durationMs }
/** Internal field. Do not use. */ // --- INTERNAL FIELDS ---
val _artistGroupingId: Long
get() = _artistGroupingName.toMusicId() val _rawArtist: Artist.Raw
get() = Artist.Raw(name = raw.artistName, sortName = raw.artistSortName)
/** Internal field. Do not use. */
val _isMissingArtist: Boolean val _isMissingArtist: Boolean
get() = _artist == null get() = _artist == null
/** Internal method. Do not use. */
fun _link(artist: Artist) { fun _link(artist: Artist) {
_artist = artist _artist = artist
} }
data class Raw(
val mediaStoreId: Long?,
val name: String?,
val sortName: String?,
val date: Date?,
val releaseType: ReleaseType?,
val artistName: String?,
val artistSortName: String?,
) {
val groupingId: Long
init {
var groupingIdResult = artistName.toMusicId()
groupingIdResult = 31 * groupingIdResult + name.toMusicId()
groupingId = groupingIdResult
}
}
} }
/** /**
@ -251,8 +305,7 @@ data class Album(
* artist or artist field, not the individual performers of an artist. * artist or artist field, not the individual performers of an artist.
*/ */
data class Artist( data class Artist(
override val rawName: String?, private val raw: Raw,
override val rawSortName: String?,
/** The albums of this artist. */ /** The albums of this artist. */
val albums: List<Album> val albums: List<Album>
) : MusicParent() { ) : MusicParent() {
@ -265,23 +318,33 @@ data class Artist(
override val id: Long override val id: Long
get() = rawName.toMusicId() get() = rawName.toMusicId()
override val rawName = raw.name
override val rawSortName = raw.sortName
override fun resolveName(context: Context) = rawName ?: context.getString(R.string.def_artist) override fun resolveName(context: Context) = rawName ?: context.getString(R.string.def_artist)
/** The songs of this artist. */
override val songs = albums.flatMap { it.songs } override val songs = albums.flatMap { it.songs }
val durationMs: Long /** The total duration of songs in this artist, in millis. */
get() = songs.sumOf { it.durationMs } val durationMs = songs.sumOf { it.durationMs }
data class Raw(val name: String?, val sortName: String?) {
val groupingId = name.toMusicId()
}
} }
/** The data object for a genre. */ /** The data object for a genre. */
data class Genre(override val rawName: String?, override val songs: List<Song>) : MusicParent() { data class Genre(private val raw: Raw, override val songs: List<Song>) : MusicParent() {
init { init {
for (song in songs) { for (song in songs) {
song._link(this) song._link(this)
} }
} }
override val rawName: String?
get() = raw.name
// Sort tags don't make sense on genres // Sort tags don't make sense on genres
override val rawSortName: String? override val rawSortName: String?
get() = rawName get() = rawName
@ -291,8 +354,12 @@ data class Genre(override val rawName: String?, override val songs: List<Song>)
override fun resolveName(context: Context) = rawName ?: context.getString(R.string.def_genre) override fun resolveName(context: Context) = rawName ?: context.getString(R.string.def_genre)
val durationMs: Long /** The total duration of the songs in this genre, in millis. */
get() = songs.sumOf { it.durationMs } val durationMs = songs.sumOf { it.durationMs }
data class Raw(val name: String?) {
val groupingId: Long = name.toMusicId()
}
} }
private fun String?.toMusicId(): Long { private fun String?.toMusicId(): Long {

View file

@ -67,13 +67,11 @@ class ExoPlayerBackend(private val inner: MediaStoreBackend) : Indexer.Backend {
val songs = mutableListOf<Song>() val songs = mutableListOf<Song>()
val total = cursor.count val total = cursor.count
// LEFTOFF: Make logic more consistent?
while (cursor.moveToNext()) { while (cursor.moveToNext()) {
// Note: This call to buildAudio does not populate the genre field. This is // Note: This call to buildAudio does not populate the genre field. This is
// because indexing genres is quite slow with MediaStore, and so keeping the // because indexing genres is quite slow with MediaStore, and so keeping the
// field blank on unsupported ExoPlayer formats ends up being preferable. // field blank on unsupported ExoPlayer formats ends up being preferable.
val audio = inner.buildAudio(context, cursor) val raw = inner.buildRawSong(context, cursor)
// Spin until there is an open slot we can insert a task in. Note that we do // Spin until there is an open slot we can insert a task in. Note that we do
// not add callbacks to our new tasks, as Future callbacks run on a different // not add callbacks to our new tasks, as Future callbacks run on a different
@ -88,11 +86,11 @@ class ExoPlayerBackend(private val inner: MediaStoreBackend) : Indexer.Backend {
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, audio) runningTasks[i] = Task(context, raw)
break@spin break@spin
} }
} else { } else {
runningTasks[i] = Task(context, audio) runningTasks[i] = Task(context, raw)
break@spin break@spin
} }
} }
@ -128,11 +126,11 @@ class ExoPlayerBackend(private val inner: MediaStoreBackend) : Indexer.Backend {
* Wraps an ExoPlayer metadata retrieval task in a safe abstraction. Access is done with [get]. * Wraps an ExoPlayer metadata retrieval task in a safe abstraction. Access is done with [get].
* @author OxygenCobalt * @author OxygenCobalt
*/ */
class Task(context: Context, private val audio: MediaStoreBackend.Audio) { class Task(context: Context, private val raw: Song.Raw) {
private val future = private val future =
MetadataRetriever.retrieveMetadata( MetadataRetriever.retrieveMetadata(
context, context,
MediaItem.fromUri(requireNotNull(audio.id) { "Malformed audio: No id" }.audioUri)) MediaItem.fromUri(requireNotNull(raw.mediaStoreId) { "Invalid raw: No id" }.audioUri))
/** /**
* Get the song that this task is trying to complete. If the task is still busy, this will * Get the song that this task is trying to complete. If the task is still busy, this will
@ -147,27 +145,27 @@ class Task(context: Context, private val audio: MediaStoreBackend.Audio) {
try { try {
future.get()[0].getFormat(0) future.get()[0].getFormat(0)
} catch (e: Exception) { } catch (e: Exception) {
logW("Unable to extract metadata for ${audio.title}") logW("Unable to extract metadata for ${raw.name}")
logW(e.stackTraceToString()) logW(e.stackTraceToString())
null null
} }
if (format == null) { if (format == null) {
logD("Nothing could be extracted for ${audio.title}") logD("Nothing could be extracted for ${raw.name}")
return audio.toSong() return Song(raw)
} }
// Populate the format mime type if we have one. // Populate the format mime type if we have one.
format.sampleMimeType?.let { audio.formatMimeType = it } format.sampleMimeType?.let { raw.formatMimeType = it }
val metadata = format.metadata val metadata = format.metadata
if (metadata != null) { if (metadata != null) {
completeAudio(metadata) completeAudio(metadata)
} else { } else {
logD("No metadata could be extracted for ${audio.title}") logD("No metadata could be extracted for ${raw.name}")
} }
return audio.toSong() return Song(raw)
} }
private fun completeAudio(metadata: Metadata) { private fun completeAudio(metadata: Metadata) {
@ -215,14 +213,14 @@ class Task(context: Context, private val audio: MediaStoreBackend.Audio) {
private fun populateId3v2(tags: Map<String, String>) { private fun populateId3v2(tags: Map<String, String>) {
// (Sort) Title // (Sort) Title
tags["TIT2"]?.let { audio.title = it } tags["TIT2"]?.let { raw.name = it }
tags["TSOT"]?.let { audio.sortTitle = it } tags["TSOT"]?.let { raw.sortName = it }
// Track, as NN/TT // Track, as NN/TT
tags["TRCK"]?.parsePositionNum()?.let { audio.track = it } tags["TRCK"]?.parsePositionNum()?.let { raw.track = it }
// Disc, as NN/TT // Disc, as NN/TT
tags["TPOS"]?.parsePositionNum()?.let { audio.disc = it } tags["TPOS"]?.parsePositionNum()?.let { raw.disc = it }
// Dates are somewhat complicated, as not only did their semantics change from a flat year // Dates are somewhat complicated, as not only did their semantics change from a flat year
// value in ID3v2.3 to a full ISO-8601 date in ID3v2.4, but there are also a variety of // value in ID3v2.3 to a full ISO-8601 date in ID3v2.4, but there are also a variety of
@ -236,26 +234,26 @@ class Task(context: Context, private val audio: MediaStoreBackend.Audio) {
(tags["TDOR"]?.parseTimestamp() (tags["TDOR"]?.parseTimestamp()
?: tags["TDRC"]?.parseTimestamp() ?: tags["TDRL"]?.parseTimestamp() ?: tags["TDRC"]?.parseTimestamp() ?: tags["TDRL"]?.parseTimestamp()
?: parseId3v23Date(tags)) ?: parseId3v23Date(tags))
?.let { audio.date = it } ?.let { raw.date = it }
// (Sort) Album // (Sort) Album
tags["TALB"]?.let { audio.album = it } tags["TALB"]?.let { raw.albumName = it }
tags["TSOA"]?.let { audio.sortAlbum = it } tags["TSOA"]?.let { raw.albumSortName = it }
// (Sort) Artist // (Sort) Artist
tags["TPE1"]?.let { audio.artist = it } tags["TPE1"]?.let { raw.artistName = it }
tags["TSOP"]?.let { audio.sortArtist = it } tags["TSOP"]?.let { raw.artistSortName = it }
// (Sort) Album artist // (Sort) Album artist
tags["TPE2"]?.let { audio.albumArtist = it } tags["TPE2"]?.let { raw.albumArtistName = it }
tags["TSO2"]?.let { audio.sortAlbumArtist = it } tags["TSO2"]?.let { raw.albumArtistSortName = it }
// Genre, with the weird ID3 rules. // Genre, with the weird ID3 rules.
tags["TCON"]?.let { audio.genre = it.parseId3GenreName() } tags["TCON"]?.let { raw.genreName = 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 {
audio.releaseType = it raw.albumReleaseType = it
} }
} }
@ -282,14 +280,14 @@ class Task(context: Context, private val audio: MediaStoreBackend.Audio) {
private fun populateVorbis(tags: Map<String, List<String>>) { private fun populateVorbis(tags: Map<String, List<String>>) {
// (Sort) Title // (Sort) Title
tags["TITLE"]?.let { audio.title = it[0] } tags["TITLE"]?.let { raw.name = it[0] }
tags["TITLESORT"]?.let { audio.sortTitle = it[0] } tags["TITLESORT"]?.let { raw.sortName = it[0] }
// Track // Track
tags["TRACKNUMBER"]?.run { get(0).parsePositionNum() }?.let { audio.track = it } tags["TRACKNUMBER"]?.run { get(0).parsePositionNum() }?.let { raw.track = it }
// Disc // Disc
tags["DISCNUMBER"]?.run { get(0).parsePositionNum() }?.let { audio.disc = it } tags["DISCNUMBER"]?.run { get(0).parsePositionNum() }?.let { raw.disc = it }
// Vorbis dates are less complicated, but there are still several types // Vorbis dates are less complicated, but there are still several types
// Our hierarchy for dates is as such: // Our hierarchy for dates is as such:
@ -300,25 +298,25 @@ class Task(context: Context, private val audio: MediaStoreBackend.Audio) {
(tags["ORIGINALDATE"]?.run { get(0).parseTimestamp() } (tags["ORIGINALDATE"]?.run { get(0).parseTimestamp() }
?: tags["DATE"]?.run { get(0).parseTimestamp() } ?: tags["DATE"]?.run { get(0).parseTimestamp() }
?: tags["YEAR"]?.run { get(0).parseYear() }) ?: tags["YEAR"]?.run { get(0).parseYear() })
?.let { audio.date = it } ?.let { raw.date = it }
// (Sort) Album // (Sort) Album
tags["ALBUM"]?.let { audio.album = it.joinToString() } tags["ALBUM"]?.let { raw.albumName = it.joinToString() }
tags["ALBUMSORT"]?.let { audio.sortAlbum = it.joinToString() } tags["ALBUMSORT"]?.let { raw.albumSortName = it.joinToString() }
// (Sort) Artist // (Sort) Artist
tags["ARTIST"]?.let { audio.artist = it.joinToString() } tags["ARTIST"]?.let { raw.artistName = it.joinToString() }
tags["ARTISTSORT"]?.let { audio.sortArtist = it.joinToString() } tags["ARTISTSORT"]?.let { raw.artistSortName = it.joinToString() }
// (Sort) Album artist // (Sort) Album artist
tags["ALBUMARTIST"]?.let { audio.albumArtist = it.joinToString() } tags["ALBUMARTIST"]?.let { raw.albumArtistName = it.joinToString() }
tags["ALBUMARTISTSORT"]?.let { audio.sortAlbumArtist = it.joinToString() } tags["ALBUMARTISTSORT"]?.let { raw.albumArtistSortName = it.joinToString() }
// Genre, no ID3 rules here // Genre, no ID3 rules here
tags["GENRE"]?.let { audio.genre = it.joinToString() } tags["GENRE"]?.let { raw.genreName = it.joinToString() }
// Release type // Release type
tags["RELEASETYPE"]?.parseReleaseType()?.let { audio.releaseType = it } tags["RELEASETYPE"]?.parseReleaseType()?.let { raw.albumReleaseType = it }
} }
/** /**

View file

@ -262,19 +262,7 @@ class Indexer {
} }
// Deduplicate songs to prevent (most) deformed music clones // Deduplicate songs to prevent (most) deformed music clones
songs = songs = songs.distinctBy { it._distinct }.toMutableList()
songs
.distinctBy {
it.rawName to
it._albumName to
it._artistName to
it._albumArtistName to
it._genreName to
it.track to
it.disc to
it.durationMs
}
.toMutableList()
// Ensure that sorting order is consistent so that grouping is also consistent. // Ensure that sorting order is consistent so that grouping is also consistent.
Sort(Sort.Mode.ByName, true).songsInPlace(songs) Sort(Sort.Mode.ByName, true).songsInPlace(songs)
@ -299,7 +287,7 @@ class Indexer {
*/ */
private fun buildAlbums(songs: List<Song>): List<Album> { private fun buildAlbums(songs: List<Song>): List<Album> {
val albums = mutableListOf<Album>() val albums = mutableListOf<Album>()
val songsByAlbum = songs.groupBy { it._albumGroupingId } val songsByAlbum = songs.groupBy { it._rawAlbum.groupingId }
for (entry in songsByAlbum) { for (entry in songsByAlbum) {
val albumSongs = entry.value val albumSongs = entry.value
@ -308,18 +296,10 @@ class Indexer {
// This allows us to replicate the LAST_YEAR field, which is useful as it means that // This allows us to replicate the LAST_YEAR field, which is useful as it means that
// weird years like "0" wont show up if there are alternatives. // weird years like "0" wont show up if there are alternatives.
val templateSong = val templateSong =
albumSongs.maxWith(compareBy(Sort.Mode.NullableComparator.DATE) { it._date }) albumSongs.maxWith(
compareBy(Sort.Mode.NullableComparator.DATE) { it._rawAlbum.date })
albums.add( albums.add(Album(templateSong._rawAlbum, albumSongs))
Album(
rawName = templateSong._albumName,
rawSortName = templateSong._albumSortName,
date = templateSong._date,
releaseType = templateSong._albumReleaseType ?: ReleaseType.Album(null),
coverUri = templateSong._albumCoverUri,
songs = entry.value,
_artistGroupingName = templateSong._artistGroupingName,
_artistGroupingSortName = templateSong._artistGroupingSortName))
} }
logD("Successfully built ${albums.size} albums") logD("Successfully built ${albums.size} albums")
@ -333,16 +313,12 @@ class Indexer {
*/ */
private fun buildArtists(albums: List<Album>): List<Artist> { private fun buildArtists(albums: List<Album>): List<Artist> {
val artists = mutableListOf<Artist>() val artists = mutableListOf<Artist>()
val albumsByArtist = albums.groupBy { it._artistGroupingId } val albumsByArtist = albums.groupBy { it._rawArtist.groupingId }
for (entry in albumsByArtist) { for (entry in albumsByArtist) {
// The first album will suffice for template metadata. // The first album will suffice for template metadata.
val templateAlbum = entry.value[0] val templateAlbum = entry.value[0]
artists.add( artists.add(Artist(templateAlbum._rawArtist, albums = entry.value))
Artist(
rawName = templateAlbum._artistGroupingName,
rawSortName = templateAlbum._artistGroupingSortName,
albums = entry.value))
} }
logD("Successfully built ${artists.size} artists") logD("Successfully built ${artists.size} artists")
@ -356,12 +332,12 @@ 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._genreGroupingId } val songsByGenre = songs.groupBy { it._rawGenre.groupingId }
for (entry in songsByGenre) { for (entry in songsByGenre) {
// The first song fill suffice for template metadata. // The first song fill suffice for template metadata.
val templateSong = entry.value[0] val templateSong = entry.value[0]
genres.add(Genre(rawName = templateSong._genreName, songs = entry.value)) genres.add(Genre(templateSong._rawGenre, songs = entry.value))
} }
logD("Successfully built ${genres.size} genres") logD("Successfully built ${genres.size} genres")

View file

@ -163,9 +163,9 @@ abstract class MediaStoreBackend : Indexer.Backend {
cursor: Cursor, cursor: Cursor,
emitIndexing: (Indexer.Indexing) -> Unit emitIndexing: (Indexer.Indexing) -> Unit
): List<Song> { ): List<Song> {
val audios = mutableListOf<Audio>() val rawSongs = mutableListOf<Song.Raw>()
while (cursor.moveToNext()) { while (cursor.moveToNext()) {
audios.add(buildAudio(context, cursor)) rawSongs.add(buildRawSong(context, cursor))
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).
// While this seems redundant, each call to emitIndexing checks for a // While this seems redundant, each call to emitIndexing checks for a
@ -174,9 +174,9 @@ abstract class MediaStoreBackend : Indexer.Backend {
} }
} }
// The audio is not actually complete at this point, as we cannot obtain a genre // The raw song is not actually complete at this point, as we cannot obtain a genre
// through a song query. Instead, we have to do the hack where we iterate through // through a song query. Instead, we have to do the hack where we iterate through
// every genre and assign it's name to audios that match it's child ID. // every genre and assign it's name to raw songs that match it's child IDs.
context.contentResolverSafe.useQuery( context.contentResolverSafe.useQuery(
MediaStore.Audio.Genres.EXTERNAL_CONTENT_URI, MediaStore.Audio.Genres.EXTERNAL_CONTENT_URI,
arrayOf(MediaStore.Audio.Genres._ID, MediaStore.Audio.Genres.NAME)) { genreCursor -> arrayOf(MediaStore.Audio.Genres._ID, MediaStore.Audio.Genres.NAME)) { genreCursor ->
@ -199,7 +199,9 @@ abstract class MediaStoreBackend : Indexer.Backend {
while (cursor.moveToNext()) { while (cursor.moveToNext()) {
val songId = cursor.getLong(songIdIndex) val songId = cursor.getLong(songIdIndex)
audios.find { it.id == songId }?.let { song -> song.genre = name } rawSongs
.find { it.mediaStoreId == songId }
?.let { song -> song.genreName = 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).
@ -214,7 +216,7 @@ abstract class MediaStoreBackend : Indexer.Backend {
emitIndexing(Indexer.Indexing.Indeterminate) emitIndexing(Indexer.Indexing.Indeterminate)
} }
return audios.map { it.toSong() } return rawSongs.map { Song(it) }
} }
/** /**
@ -242,11 +244,11 @@ abstract class MediaStoreBackend : Indexer.Backend {
abstract fun addDirToSelectorArgs(dir: Directory, args: MutableList<String>): Boolean abstract fun addDirToSelectorArgs(dir: Directory, args: MutableList<String>): Boolean
/** /**
* Build an [Audio] based on the current cursor values. Each implementation should try to obtain * Build an [Song.Raw] based on the current cursor values. Each implementation should try to
* an upstream [Audio] first, and then populate it with version-specific fields outlined in * obtain an upstream [Song.Raw] first, and then populate it with version-specific fields
* [projection]. * outlined in [projection].
*/ */
open fun buildAudio(context: Context, cursor: Cursor): Audio { open fun buildRawSong(context: Context, cursor: Cursor): Song.Raw {
// Initialize our cursor indices if we haven't already. // Initialize our cursor indices if we haven't already.
if (idIndex == -1) { if (idIndex == -1) {
idIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns._ID) idIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns._ID)
@ -264,105 +266,42 @@ abstract class MediaStoreBackend : Indexer.Backend {
albumArtistIndex = cursor.getColumnIndexOrThrow(AUDIO_COLUMN_ALBUM_ARTIST) albumArtistIndex = cursor.getColumnIndexOrThrow(AUDIO_COLUMN_ALBUM_ARTIST)
} }
val audio = Audio() val raw = Song.Raw()
audio.id = cursor.getLong(idIndex) raw.mediaStoreId = cursor.getLong(idIndex)
audio.title = cursor.getString(titleIndex) raw.name = cursor.getString(titleIndex)
audio.extensionMimeType = cursor.getString(mimeTypeIndex) raw.extensionMimeType = cursor.getString(mimeTypeIndex)
audio.size = cursor.getLong(sizeIndex) raw.size = cursor.getLong(sizeIndex)
audio.dateAdded = cursor.getLong(dateAddedIndex) raw.dateAdded = cursor.getLong(dateAddedIndex)
// Try to use the DISPLAY_NAME field to obtain a (probably sane) file name // Try to use the DISPLAY_NAME field to obtain a (probably sane) file name
// from the android system. // from the android system.
audio.displayName = cursor.getStringOrNull(displayNameIndex) raw.displayName = cursor.getStringOrNull(displayNameIndex)
audio.duration = cursor.getLong(durationIndex) raw.durationMs = cursor.getLong(durationIndex)
audio.date = cursor.getIntOrNull(yearIndex)?.let(Date::from) raw.date = cursor.getIntOrNull(yearIndex)?.let(Date::from)
// A non-existent album name should theoretically be the name of the folder it contained // A non-existent album name should theoretically be the name of the folder it contained
// in, but in practice it is more often "0" (as in /storage/emulated/0), even when it the // in, but in practice it is more often "0" (as in /storage/emulated/0), even when it the
// file is not actually in the root internal storage directory. We can't do anything to // file is not actually in the root internal storage directory. We can't do anything to
// fix this, really. // fix this, really.
audio.album = cursor.getString(albumIndex) raw.albumName = cursor.getString(albumIndex)
audio.albumId = cursor.getLong(albumIdIndex) raw.albumMediaStoreId = cursor.getLong(albumIdIndex)
// Android does not make a non-existent artist tag null, it instead fills it in // Android does not make a non-existent artist tag null, it instead fills it in
// as <unknown>, which makes absolutely no sense given how other fields default // as <unknown>, which makes absolutely no sense given how other fields default
// to null if they are not present. If this field is <unknown>, null it so that // to null if they are not present. If this field is <unknown>, null it so that
// it's easier to handle later. // it's easier to handle later.
audio.artist = raw.artistName =
cursor.getString(artistIndex).run { cursor.getString(artistIndex).run {
if (this != MediaStore.UNKNOWN_STRING) this else null if (this != MediaStore.UNKNOWN_STRING) this else null
} }
// The album artist field is nullable and never has placeholder values. // The album artist field is nullable and never has placeholder values.
audio.albumArtist = cursor.getStringOrNull(albumArtistIndex) raw.albumArtistName = cursor.getStringOrNull(albumArtistIndex)
return audio return raw
}
/**
* Represents a song as it is represented by MediaStore. This is progressively mutated in the
* chain of Backend instances until it is complete enough to be transformed into an immutable
* song.
*/
data class Audio(
var id: Long? = null,
var title: String? = null,
var sortTitle: String? = null,
var displayName: String? = null,
var dir: Directory? = null,
var extensionMimeType: String? = null,
var formatMimeType: String? = null,
var size: Long? = null,
var dateAdded: Long? = null,
var duration: Long? = null,
var track: Int? = null,
var disc: Int? = null,
var date: Date? = null,
var albumId: Long? = null,
var album: String? = null,
var sortAlbum: String? = null,
var releaseType: ReleaseType? = null,
var artist: String? = null,
var sortArtist: String? = null,
var albumArtist: String? = null,
var sortAlbumArtist: String? = null,
var genre: String? = null
) {
fun toSong() =
Song(
// Assert that the fields that should always exist are present. I can't confirm
// that every device provides these fields, but it seems likely that they do.
rawName = requireNotNull(title) { "Malformed audio: No title" },
rawSortName = sortTitle,
path =
Path(
name = requireNotNull(displayName) { "Malformed audio: No display name" },
parent = requireNotNull(dir) { "Malformed audio: No parent directory" }),
uri = requireNotNull(id) { "Malformed audio: No id" }.audioUri,
mimeType =
MimeType(
fromExtension =
requireNotNull(extensionMimeType) { "Malformed audio: No mime type" },
fromFormat = formatMimeType),
size = requireNotNull(size) { "Malformed audio: No size" },
dateAdded = requireNotNull(dateAdded) { "Malformed audio: No date added" },
durationMs = requireNotNull(duration) { "Malformed audio: No duration" },
track = track,
disc = disc,
_date = date,
_albumName = requireNotNull(album) { "Malformed audio: No album name" },
_albumSortName = sortAlbum,
_albumReleaseType = releaseType,
_albumCoverUri =
requireNotNull(albumId) { "Malformed audio: No album id" }.albumCoverUri,
_artistName = artist,
_artistSortName = sortArtist,
_albumArtistName = albumArtist,
_albumArtistSortName = sortAlbumArtist,
_genreName = genre)
} }
companion object { companion object {
@ -415,8 +354,8 @@ class Api21MediaStoreBackend : MediaStoreBackend() {
return true return true
} }
override fun buildAudio(context: Context, cursor: Cursor): Audio { override fun buildRawSong(context: Context, cursor: Cursor): Song.Raw {
val audio = super.buildAudio(context, cursor) val raw = super.buildRawSong(context, cursor)
// Initialize our indices if we have not already. // Initialize our indices if we have not already.
if (trackIndex == -1) { if (trackIndex == -1) {
@ -430,8 +369,8 @@ class Api21MediaStoreBackend : MediaStoreBackend() {
// that this only applies to below API 29, as beyond API 29, this field not being // that this only applies to below API 29, as beyond API 29, this field not being
// present would completely break the scoped storage system. Fill it in with DATA // present would completely break the scoped storage system. Fill it in with DATA
// if it's not available. // if it's not available.
if (audio.displayName == null) { if (raw.displayName == null) {
audio.displayName = data.substringAfterLast(File.separatorChar, "").ifEmpty { null } raw.displayName = data.substringAfterLast(File.separatorChar, "").ifEmpty { null }
} }
// Find the volume that transforms the DATA field into a relative path. This is // Find the volume that transforms the DATA field into a relative path. This is
@ -441,18 +380,18 @@ class Api21MediaStoreBackend : MediaStoreBackend() {
val volumePath = volume.directoryCompat ?: continue val volumePath = volume.directoryCompat ?: continue
val strippedPath = rawPath.removePrefix(volumePath) val strippedPath = rawPath.removePrefix(volumePath)
if (strippedPath != rawPath) { if (strippedPath != rawPath) {
audio.dir = Directory.from(volume, strippedPath) raw.directory = Directory.from(volume, strippedPath)
break break
} }
} }
val rawTrack = cursor.getIntOrNull(trackIndex) val rawTrack = cursor.getIntOrNull(trackIndex)
if (rawTrack != null) { if (rawTrack != null) {
rawTrack.unpackTrackNo()?.let { audio.track = it } rawTrack.unpackTrackNo()?.let { raw.track = it }
rawTrack.unpackDiscNo()?.let { audio.disc = it } rawTrack.unpackDiscNo()?.let { raw.disc = it }
} }
return audio return raw
} }
} }
@ -485,8 +424,8 @@ open class BaseApi29MediaStoreBackend : MediaStoreBackend() {
return true return true
} }
override fun buildAudio(context: Context, cursor: Cursor): Audio { override fun buildRawSong(context: Context, cursor: Cursor): Song.Raw {
val audio = super.buildAudio(context, cursor) val raw = super.buildRawSong(context, cursor)
if (volumeIndex == -1) { if (volumeIndex == -1) {
volumeIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.VOLUME_NAME) volumeIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.VOLUME_NAME)
@ -501,10 +440,10 @@ open class BaseApi29MediaStoreBackend : MediaStoreBackend() {
// This is what we use for the Directory's volume. // This is what we use for the Directory's volume.
val volume = volumes.find { it.mediaStoreVolumeNameCompat == volumeName } val volume = volumes.find { it.mediaStoreVolumeNameCompat == volumeName }
if (volume != null) { if (volume != null) {
audio.dir = Directory.from(volume, relativePath) raw.directory = Directory.from(volume, relativePath)
} }
return audio return raw
} }
} }
@ -520,8 +459,8 @@ open class Api29MediaStoreBackend : BaseApi29MediaStoreBackend() {
override val projection: Array<String> override val projection: Array<String>
get() = super.projection + arrayOf(MediaStore.Audio.AudioColumns.TRACK) get() = super.projection + arrayOf(MediaStore.Audio.AudioColumns.TRACK)
override fun buildAudio(context: Context, cursor: Cursor): Audio { override fun buildRawSong(context: Context, cursor: Cursor): Song.Raw {
val audio = super.buildAudio(context, cursor) val raw = super.buildRawSong(context, cursor)
if (trackIndex == -1) { if (trackIndex == -1) {
trackIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.TRACK) trackIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.TRACK)
@ -531,11 +470,11 @@ open class Api29MediaStoreBackend : BaseApi29MediaStoreBackend() {
// Use the old field instead. // Use the old field instead.
val rawTrack = cursor.getIntOrNull(trackIndex) val rawTrack = cursor.getIntOrNull(trackIndex)
if (rawTrack != null) { if (rawTrack != null) {
rawTrack.unpackTrackNo()?.let { audio.track = it } rawTrack.unpackTrackNo()?.let { raw.track = it }
rawTrack.unpackDiscNo()?.let { audio.disc = it } rawTrack.unpackDiscNo()?.let { raw.disc = it }
} }
return audio return raw
} }
} }
@ -556,8 +495,8 @@ class Api30MediaStoreBackend : BaseApi29MediaStoreBackend() {
MediaStore.Audio.AudioColumns.CD_TRACK_NUMBER, MediaStore.Audio.AudioColumns.CD_TRACK_NUMBER,
MediaStore.Audio.AudioColumns.DISC_NUMBER) MediaStore.Audio.AudioColumns.DISC_NUMBER)
override fun buildAudio(context: Context, cursor: Cursor): Audio { override fun buildRawSong(context: Context, cursor: Cursor): Song.Raw {
val audio = super.buildAudio(context, cursor) val raw = super.buildRawSong(context, cursor)
// Populate our indices if we have not already. // Populate our indices if we have not already.
if (trackIndex == -1) { if (trackIndex == -1) {
@ -569,9 +508,9 @@ class Api30MediaStoreBackend : BaseApi29MediaStoreBackend() {
// the tag itself, which is to say that it is formatted as NN/TT tracks, where // the tag itself, which is to say that it is formatted as NN/TT tracks, where
// N is the number and T is the total. Parse the number while leaving out the // N is the number and T is the total. Parse the number while leaving out the
// total, as we have no use for it. // total, as we have no use for it.
cursor.getStringOrNull(trackIndex)?.parsePositionNum()?.let { audio.track = it } cursor.getStringOrNull(trackIndex)?.parsePositionNum()?.let { raw.track = it }
cursor.getStringOrNull(discIndex)?.parsePositionNum()?.let { audio.disc = it } cursor.getStringOrNull(discIndex)?.parsePositionNum()?.let { raw.disc = it }
return audio return raw
} }
} }

View file

@ -407,7 +407,7 @@ class MediaSessionComponent(
if (settings.useAltNotifAction) { if (settings.useAltNotifAction) {
PlaybackStateCompat.CustomAction.Builder( PlaybackStateCompat.CustomAction.Builder(
PlaybackService.ACTION_INVERT_SHUFFLE, PlaybackService.ACTION_INVERT_SHUFFLE,
context.getString(R.string.desc_change_repeat), context.getString(R.string.desc_shuffle),
if (playbackManager.isShuffled) { if (playbackManager.isShuffled) {
R.drawable.ic_shuffle_on_24 R.drawable.ic_shuffle_on_24
} else { } else {