Rewrite Music Loading a g a i n

Rewrite the music loading for [hopefully] the final time, now with Artists/Albums/Media instead of just AudioColumns, improved genre loading, and easier to implement album-art. Hopefully this system will stick.
This commit is contained in:
OxygenCobalt 2020-08-21 11:48:05 -06:00
parent 160013bbe9
commit b1be2802cf
9 changed files with 366 additions and 215 deletions

View file

@ -0,0 +1,38 @@
package org.oxycblt.auxio.music
// Compatibility layer to convert old int-based genres to new genres
val ID3_GENRES = arrayOf<String>(
"Blues", "Classic Rock", "Country", "Dance", "Disco", "Funk", "Grunge", "Hip-Hop", "Jazz",
"Metal", "New Age", "Oldies", "Other", "Pop", "R&B", "Rap", "Reggae", "Rock", "Techno",
"Industrial", "Alternative", "Ska", "Death Metal", "Pranks", "Soundtrack", "Euro-Techno",
"Ambient", "Trip-Hop", "Vocal", "Jazz+Funk", "Fusion", "Trance", "Classical", "Instrumental",
"Acid", "House", "Game", "Sound Clip", "Gospel", "Noise", "AlternRock", "Bass", "Soul", "Punk",
"Space", "Meditative", "Instrumental Pop", "Instrumental Rock", "Ethnic", "Gothic", "Darkwave",
"Techno-Industrial", "Electronic", "Pop-Folk", "Eurodance", "Dream", "Southern Rock", "Comedy",
"Cult", "Gangsta", "Top 40", "Christian Rap", "Pop/Funk", "Jungle", "Native American",
"Cabaret", "New Wave", "Psychadelic", "Rave", "Showtunes", "Trailer", "Lo-Fi", "Tribal",
"Acid Punk", "Acid Jazz", "Polka", "Retro", "Musical", "Rock & Roll", "Hard Rock",
// Winamp extensions
"Folk", "Folk-Rock", "National Folk", "Swing", "Fast Fusion", "Bebob", "Latin", "Revival",
"Celtic", "Bluegrass", "Avantgarde", "Gothic Rock", "Progressive Rock", "Psychedelic Rock",
"Symphonic Rock", "Slow Rock", "Big Band", "Chorus", "Easy Listening", "Acoustic", "Humour",
"Speech", "Chanson", "Opera", "Chamber Music", "Sonata", "Symphony", "Booty Bass", "Primus",
"Porn Groove", "Satire", "Slow Jam", "Club", "Tango", "Samba", "Folklore", "Ballad",
"Power Ballad", "Rhythmic Soul", "Freestyle", "Duet", "Punk Rock", "Drum Solo", "A capella",
"Euro-House", "Dance Hall", "Goa", "Drum & Bass", "Club-House", "Hardcore", "Terror", "Indie",
"Britpop", "Negerpunk", "Polsk Punk", "Beat", "Christian Gangsta", "Heavy Metal", "Black Metal",
"Crossover", "Contemporary Christian", "Christian Rock", "Merengue", "Salsa", "Thrash Metal",
"Anime", "JPop", "Synthpop"
)
const val PAREN_FILTER = "()"
fun intToNamedGenre(genre: String): String? {
// Strip the genres of any parentheses, and convert it to an int
val intGenre = genre.filterNot {
PAREN_FILTER.indexOf(it) > -1
}.toInt()
return ID3_GENRES.getOrNull(intGenre)
}

View file

@ -3,10 +3,14 @@ package org.oxycblt.auxio.music
import android.app.Application import android.app.Application
import android.content.ContentResolver import android.content.ContentResolver
import android.database.Cursor import android.database.Cursor
import android.provider.MediaStore import android.provider.MediaStore.Audio.Albums
import android.provider.MediaStore.Audio.Artists
import android.provider.MediaStore.Audio.Genres import android.provider.MediaStore.Audio.Genres
import android.provider.MediaStore.Audio.AudioColumns import android.provider.MediaStore.Audio.Media
import android.util.Log import android.util.Log
import org.oxycblt.auxio.music.models.Album
import org.oxycblt.auxio.music.models.Artist
import org.oxycblt.auxio.music.models.Genre
import org.oxycblt.auxio.music.models.Song import org.oxycblt.auxio.music.models.Song
enum class MusicLoaderResponse { enum class MusicLoaderResponse {
@ -17,62 +21,44 @@ enum class MusicLoaderResponse {
// FIXME: This thing probably has some memory leaks *somewhere* // FIXME: This thing probably has some memory leaks *somewhere*
class MusicLoader(private val app: Application) { class MusicLoader(private val app: Application) {
var genres = mutableListOf<Genre>()
var artists = mutableListOf<Artist>()
var albums = mutableListOf<Album>()
var songs = mutableListOf<Song>() var songs = mutableListOf<Song>()
var genres = mutableListOf<Pair<Long, String>>()
private var musicCursor: Cursor? = null
private var genreCursor: Cursor? = null private var genreCursor: Cursor? = null
private var artistCursor: Cursor? = null
private var albumCursor: Cursor? = null
private var songCursor: Cursor? = null
val response: MusicLoaderResponse val response: MusicLoaderResponse
val resolver: ContentResolver = app.contentResolver
init { init {
response = findMusic() response = findMusic()
} }
private fun findMusic(): MusicLoaderResponse { private fun findMusic(): MusicLoaderResponse {
genreCursor = getGenreCursor(
app.contentResolver
)
useGenreCursor()
indexMusic()
try { try {
loadGenres()
loadArtists()
loadAlbums()
loadSongs()
} catch (error: Exception) { } catch (error: Exception) {
Log.e(this::class.simpleName, "Something went horribly wrong.") Log.e(this::class.simpleName, "Something went horribly wrong.")
error.printStackTrace() error.printStackTrace()
musicCursor?.close()
return MusicLoaderResponse.FAILURE return MusicLoaderResponse.FAILURE
} }
// If the main loading completed without a failure, return DONE or return MusicLoaderResponse.DONE
// NO_MUSIC depending on if any music was found.
return if (songs.size > 0) {
Log.d(
this::class.simpleName,
"Successfully found " + songs.size.toString() + " Songs."
)
MusicLoaderResponse.DONE
} else {
Log.d(
this::class.simpleName,
"No music was found."
)
MusicLoaderResponse.NO_MUSIC
}
} }
private fun getGenreCursor(resolver: ContentResolver): Cursor? { private fun loadGenres() {
Log.i(this::class.simpleName, "Getting genre cursor.") Log.d(this::class.simpleName, "Starting genre search...")
// Get every Genre indexed by the android system, for some reason // First, get a cursor for every genre in the android system
// you cant directly get this from the plain music cursor. genreCursor = resolver.query(
return resolver.query(
Genres.EXTERNAL_CONTENT_URI, Genres.EXTERNAL_CONTENT_URI,
arrayOf( arrayOf(
Genres._ID, // 0 Genres._ID, // 0
@ -81,116 +67,212 @@ class MusicLoader(private val app: Application) {
null, null, null, null,
Genres.DEFAULT_SORT_ORDER Genres.DEFAULT_SORT_ORDER
) )
}
private fun getMusicCursor(resolver: ContentResolver, genreId: Long): Cursor? {
Log.i(this::class.simpleName, "Getting music cursor.")
// Get all the values that can be retrieved by the cursor.
// The rest are retrieved using MediaMetadataExtractor and
// stored into a database.
return resolver.query(
Genres.Members.getContentUri("external", genreId),
arrayOf(
AudioColumns._ID, // 0
AudioColumns.DISPLAY_NAME, // 1
AudioColumns.TITLE, // 2
AudioColumns.ARTIST, // 3
AudioColumns.ALBUM, // 4
AudioColumns.YEAR, // 5
AudioColumns.TRACK, // 6
AudioColumns.DURATION // 7
),
AudioColumns.IS_MUSIC + "=1", null,
MediaStore.Audio.Media.DEFAULT_SORT_ORDER
)
}
// Use the genre cursor to index all the genres the android system has indexed.
private fun useGenreCursor() {
// TODO: Move genre to its own model, even if its just two values
// And then process those into Genre objects
genreCursor?.use { cursor -> genreCursor?.use { cursor ->
// Don't even bother running if there's nothing to process.
if (cursor.count == 0) {
return
}
val idIndex = cursor.getColumnIndexOrThrow(Genres._ID) val idIndex = cursor.getColumnIndexOrThrow(Genres._ID)
val nameIndex = cursor.getColumnIndexOrThrow(Genres.NAME) val nameIndex = cursor.getColumnIndexOrThrow(Genres.NAME)
while (cursor.moveToNext()) { while (cursor.moveToNext()) {
genres.add( val id = cursor.getLong(idIndex)
Pair<Long, String>( var name = cursor.getString(nameIndex)
cursor.getLong(idIndex),
cursor.getString(nameIndex)
)
)
Log.i(this::class.simpleName, cursor.getString(nameIndex)) // If a genre is still in an old int-based format [Android formats it as "(INT)"],
// convert that to the corresponding ID3 genre.
if (name.contains("[0-9][()]")) {
name = intToNamedGenre(name)
}
genres.add(
Genre(
id, name
)
)
} }
cursor.close() cursor.close()
} }
Log.i(this::class.simpleName, "Retrieved " + genres.size.toString() + " Genres.") // Remove dupes
genres = genres.distinctBy {
it.name
}.toMutableList()
Log.d(
this::class.simpleName,
"Genre search finished with " +
genres.size.toString() +
" genres found."
)
} }
// Use the cursor index music files from the shared storage. private fun loadArtists() {
private fun indexMusic() { Log.d(this::class.simpleName, "Starting artist search...")
Log.i(this::class.simpleName, "Starting music search...")
// So, android has a brain aneurysm if you try to get an audio genre through // To associate artists with their genres, a new cursor is
// AudioColumns. As a result, I just index every genre's name & ID, create a cursor // created with all the artists of that type.
// of music for that genres ID, and then load and iterate through them normally,
// creating using the genres name as that songs Genre.
// God why do I have to do this
for (genre in genres) { for (genre in genres) {
val musicCursor = getMusicCursor(app.contentResolver, genre.first) artistCursor = resolver.query(
Genres.Members.getContentUri("external", genre.id),
arrayOf(
Artists._ID,
Artists.ARTIST
),
null, null,
Artists.DEFAULT_SORT_ORDER
)
musicCursor?.use { cursor -> artistCursor?.use { cursor ->
val idIndex = cursor.getColumnIndexOrThrow(Artists._ID)
// Don't run the more expensive file loading operations if there val nameIndex = cursor.getColumnIndexOrThrow(Artists.ARTIST)
// is no music to index.
if (cursor.count == 0) {
return
}
val idIndex = cursor.getColumnIndexOrThrow(AudioColumns._ID)
val displayIndex = cursor.getColumnIndexOrThrow(AudioColumns.DISPLAY_NAME)
val titleIndex = cursor.getColumnIndexOrThrow(AudioColumns.TITLE)
val artistIndex = cursor.getColumnIndexOrThrow(AudioColumns.ARTIST)
val albumIndex = cursor.getColumnIndexOrThrow(AudioColumns.ALBUM)
val yearIndex = cursor.getColumnIndexOrThrow(AudioColumns.YEAR)
val trackIndex = cursor.getColumnIndexOrThrow(AudioColumns.TRACK)
val durationIndex = cursor.getColumnIndexOrThrow(AudioColumns.DURATION)
while (cursor.moveToNext()) { while (cursor.moveToNext()) {
val id = cursor.getLong(idIndex) val id = cursor.getLong(idIndex)
val display = cursor.getString(displayIndex) val name = cursor.getString(nameIndex)
// Get the basic metadata from the cursor // If an artist has already been added [Which is very likely due to how genres
val title = cursor.getString(titleIndex) ?: display // are processed], add the genre to the existing artist instead of creating a
// new one.
val existingArtist = artists.find { it.name == name }
if (existingArtist != null) {
existingArtist.genres.add(genre.name)
} else {
artists.add(
Artist(
id, name,
mutableListOf(genre.name)
)
)
}
}
cursor.close()
}
}
// Remove dupes [Just in case]
artists = artists.distinctBy {
it.name to it.genres
}.toMutableList()
Log.d(
this::class.simpleName,
"Artist search finished with " +
artists.size.toString() +
" artists found."
)
}
private fun loadAlbums() {
Log.d(this::class.simpleName, "Starting album search...")
albumCursor = resolver.query(
Albums.EXTERNAL_CONTENT_URI,
arrayOf(
Albums._ID,
Albums.ALBUM,
Albums.ARTIST,
// FIXME: May be an issue for albums whose songs released in multiple years
Albums.FIRST_YEAR,
Albums.NUMBER_OF_SONGS
),
null, null,
Albums.DEFAULT_SORT_ORDER
)
albumCursor?.use { cursor ->
val idIndex = cursor.getColumnIndexOrThrow(Albums._ID)
val nameIndex = cursor.getColumnIndexOrThrow(Albums.ALBUM)
val artistIndex = cursor.getColumnIndexOrThrow(Albums.ARTIST)
val yearIndex = cursor.getColumnIndexOrThrow(Albums.FIRST_YEAR)
val numIndex = cursor.getColumnIndexOrThrow(Albums.NUMBER_OF_SONGS)
while (cursor.moveToNext()) {
val id = cursor.getLong(idIndex)
val name = cursor.getString(nameIndex)
val artist = cursor.getString(artistIndex) val artist = cursor.getString(artistIndex)
val album = cursor.getString(albumIndex)
val year = cursor.getInt(yearIndex) val year = cursor.getInt(yearIndex)
val track = cursor.getInt(trackIndex) val numSongs = cursor.getInt(numIndex)
val duration = cursor.getLong(durationIndex)
// TODO: Add album art [But its loaded separately, as that will take a bit] albums.add(
// TODO: Add genres whenever android hasn't borked it Album(
songs.add( id, name, artist,
Song( year, numSongs
id, title, artist, album,
genre.second, year, track, duration
) )
) )
} }
cursor.close() cursor.close()
} }
// Remove dupes
albums = albums.distinctBy {
it.title to it.artistName to it.year to it.numSongs
}.toMutableList()
Log.d(
this::class.simpleName,
"Album search finished with " +
albums.size.toString() +
" albums found."
)
} }
private fun loadSongs() {
Log.d(this::class.simpleName, "Starting song search...")
songCursor = resolver.query(
Media.EXTERNAL_CONTENT_URI,
arrayOf(
Media._ID, // 0
Media.DISPLAY_NAME, // 1
Media.TITLE, // 2
Media.ALBUM, // 4
Media.TRACK, // 6
Media.DURATION // 7
),
Media.IS_MUSIC + "=1", null,
Media.DEFAULT_SORT_ORDER
)
songCursor?.use { cursor ->
val idIndex = cursor.getColumnIndexOrThrow(Media._ID)
val fileIndex = cursor.getColumnIndexOrThrow(Media.DISPLAY_NAME)
val titleIndex = cursor.getColumnIndexOrThrow(Media.TITLE)
val albumIndex = cursor.getColumnIndexOrThrow(Media.ALBUM)
val trackIndex = cursor.getColumnIndexOrThrow(Media.TRACK)
val durationIndex = cursor.getColumnIndexOrThrow(Media.DURATION)
while (cursor.moveToNext()) {
val id = cursor.getLong(idIndex)
val title = cursor.getString(titleIndex) ?: cursor.getString(fileIndex)
val album = cursor.getString(albumIndex)
val track = cursor.getInt(trackIndex)
val duration = cursor.getLong(durationIndex)
songs.add(
Song(
id, title, album,
track, duration
)
)
}
cursor.close()
}
// Remove dupes
songs = songs.distinctBy {
it.title to it.albumName to it.track to it.duration
}.toMutableList()
Log.d(
this::class.simpleName,
"Song search finished with " +
songs.size.toString() +
" songs found."
)
} }
} }

View file

@ -22,15 +22,25 @@ class MusicRepository {
val songs: LiveData<List<Song>> get() = mSongs val songs: LiveData<List<Song>> get() = mSongs
suspend fun init(app: Application): MusicLoaderResponse { suspend fun init(app: Application): MusicLoaderResponse {
Log.i(this::class.simpleName, "Starting initial music load")
val loader = MusicLoader(app) val loader = MusicLoader(app)
if (loader.response == MusicLoaderResponse.DONE) { if (loader.response == MusicLoaderResponse.DONE) {
// If the loading succeeds, then process the songs into lists of // If the loading succeeds, then process the songs into lists of
// songs, albums, and artists on the main thread. // songs, albums, and artists on the main thread.
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
mSongs.value = processSongs(loader.songs) val sorter = MusicSorter(
mAlbums.value = sortIntoAlbums(mSongs.value as MutableList<Song>) loader.artists,
mArtists.value = sortIntoArtists(mAlbums.value as MutableList<Album>) loader.albums,
loader.songs
)
mSongs.value = sorter.songs
mAlbums.value = sorter.albums
mArtists.value = sorter.artists
Log.i(this::class.simpleName, "Finished initial music load.")
} }
} }

View file

@ -0,0 +1,92 @@
package org.oxycblt.auxio.music
import android.util.Log
import org.oxycblt.auxio.music.models.Album
import org.oxycblt.auxio.music.models.Artist
import org.oxycblt.auxio.music.models.Song
class MusicSorter(
val artists: MutableList<Artist>,
val albums: MutableList<Album>,
val songs: MutableList<Song>
) {
init {
sortSongsIntoAlbums()
sortAlbumsIntoArtists()
}
private fun sortSongsIntoAlbums() {
Log.d(this::class.simpleName, "Sorting songs into albums...")
val unknownSongs = songs.toMutableList()
for (album in albums) {
// Find all songs that match the current album title
val albumSongs = songs.filter { it.albumName == album.title }
// And then add them to the album
album.songs.addAll(albumSongs)
unknownSongs.removeAll(albumSongs)
}
// Any remaining songs will be added to an unknown album
if (unknownSongs.size > 0) {
// Reuse an existing unknown albumif one is found
val unknownAlbum = albums.find { it.title == null } ?: Album()
unknownAlbum.songs.addAll(unknownSongs)
unknownAlbum.numSongs = unknownAlbum.songs.size
for (song in unknownSongs) {
song.album = unknownAlbum
}
albums.add(unknownAlbum)
Log.d(
this::class.simpleName,
"Placed " + unknownSongs.size.toString() + " songs into an unknown album"
)
}
}
private fun sortAlbumsIntoArtists() {
Log.d(this::class.simpleName, "Sorting albums into artists...")
val unknownAlbums = albums.toMutableList()
for (artist in artists) {
// Find all albums that match the current artist name
val artistAlbums = albums.filter { it.artistName == artist.name }
// And then add them to the album, along with refreshing the amount of albums
artist.albums.addAll(artistAlbums)
artist.numAlbums = artist.albums.size
unknownAlbums.removeAll(artistAlbums)
}
// Any remaining albums will be added to an unknown artist
if (unknownAlbums.size > 0) {
// Reuse an existing unknown artist if one is found
val unknownArtist = artists.find { it.name == null } ?: Artist()
unknownArtist.albums.addAll(unknownAlbums)
unknownArtist.numAlbums = albums.size
for (album in unknownAlbums) {
album.artist = unknownArtist
}
artists.add(unknownArtist)
Log.d(
this::class.simpleName,
"Placed " + unknownAlbums.size.toString() + " albums into an unknown album"
)
}
}
}

View file

@ -1,49 +0,0 @@
package org.oxycblt.auxio.music
import org.oxycblt.auxio.music.models.Album
import org.oxycblt.auxio.music.models.Artist
import org.oxycblt.auxio.music.models.Song
// Sort a list of Song objects into lists of songs, albums, and artists.
fun processSongs(songs: MutableList<Song>): MutableList<Song> {
// Eliminate all duplicates from the list
// excluding the ID, as that's guaranteed to be unique [I think]
return songs.distinctBy {
it.name to it.artist to it.album to it.year to it.track to it.duration
}.toMutableList()
}
// Sort a list of song objects into albums
fun sortIntoAlbums(songs: MutableList<Song>): MutableList<Album> {
val songsByAlbum = songs.groupBy { it.album }
val albumList = mutableListOf<Album>()
songsByAlbum.keys.iterator().forEach { album ->
val albumSongs = songsByAlbum[album]
albumSongs?.let {
albumList.add(
Album(albumSongs)
)
}
}
return albumList
}
// Sort a list of album objects into artists
fun sortIntoArtists(albums: MutableList<Album>): MutableList<Artist> {
val albumsByArtist = albums.groupBy { it.artist }
val artistList = mutableListOf<Artist>()
albumsByArtist.keys.iterator().forEach { artist ->
val artistAlbums = albumsByArtist[artist]
artistAlbums?.let {
artistList.add(
Artist(artistAlbums)
)
}
}
return artistList
}

View file

@ -2,30 +2,13 @@ package org.oxycblt.auxio.music.models
// Abstraction for Song // Abstraction for Song
data class Album( data class Album(
var songs: List<Song> val id: Long = 0L,
val title: String? = null,
val artistName: String? = null,
val year: Int = 0,
var numSongs: Int = 0
) { ) {
var title: String? = null lateinit var artist: Artist
var artist: String? = null
var year: Int = 0
init { val songs = mutableListOf<Song>()
// Iterate through the child songs and inherit the first valid value
// for the Album Name, Artist, and Year
for (song in songs) {
if (song.album != null) {
title = song.album
}
if (song.artist != null) {
artist = song.artist
}
if (song.year != 0) {
year = song.year
}
}
// Also sort the songs by track
songs = songs.sortedBy { it.track }
}
} }

View file

@ -2,19 +2,10 @@ package org.oxycblt.auxio.music.models
// Abstraction for mAlbums // Abstraction for mAlbums
data class Artist( data class Artist(
private var albums: List<Album> val id: Long = 0,
val name: String? = null,
val genres: MutableList<String?> = mutableListOf(null)
) { ) {
var name: String? = null val albums = mutableListOf<Album>()
var numAlbums = 0
init {
// Like Album, iterate through the child albums and pick out the first valid for artist
for (album in albums) {
if (album.artist != null) {
name = album.artist
}
}
// Also sort the mAlbums by year
albums = albums.sortedBy { it.year }
}
} }

View file

@ -0,0 +1,6 @@
package org.oxycblt.auxio.music.models
data class Genre(
val id: Long,
val name: String?
)

View file

@ -3,12 +3,10 @@ package org.oxycblt.auxio.music.models
// Class containing all relevant values for a song. // Class containing all relevant values for a song.
data class Song( data class Song(
val id: Long, val id: Long,
val name: String?, val title: String,
val artist: String?, val albumName: String,
val album: String?,
val genre: String?,
val year: Int,
val track: Int, val track: Int,
val duration: Long, val duration: Long
val coverData: ByteArray? = null ) {
) lateinit var album: Album
}