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.content.ContentResolver
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.AudioColumns
import android.provider.MediaStore.Audio.Media
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
enum class MusicLoaderResponse {
@ -17,62 +21,44 @@ enum class MusicLoaderResponse {
// FIXME: This thing probably has some memory leaks *somewhere*
class MusicLoader(private val app: Application) {
var genres = mutableListOf<Genre>()
var artists = mutableListOf<Artist>()
var albums = mutableListOf<Album>()
var songs = mutableListOf<Song>()
var genres = mutableListOf<Pair<Long, String>>()
private var musicCursor: 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 resolver: ContentResolver = app.contentResolver
init {
response = findMusic()
}
private fun findMusic(): MusicLoaderResponse {
genreCursor = getGenreCursor(
app.contentResolver
)
useGenreCursor()
indexMusic()
try {
loadGenres()
loadArtists()
loadAlbums()
loadSongs()
} catch (error: Exception) {
Log.e(this::class.simpleName, "Something went horribly wrong.")
error.printStackTrace()
musicCursor?.close()
return MusicLoaderResponse.FAILURE
}
// If the main loading completed without a failure, return DONE or
// 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
}
return MusicLoaderResponse.DONE
}
private fun getGenreCursor(resolver: ContentResolver): Cursor? {
Log.i(this::class.simpleName, "Getting genre cursor.")
private fun loadGenres() {
Log.d(this::class.simpleName, "Starting genre search...")
// Get every Genre indexed by the android system, for some reason
// you cant directly get this from the plain music cursor.
return resolver.query(
// First, get a cursor for every genre in the android system
genreCursor = resolver.query(
Genres.EXTERNAL_CONTENT_URI,
arrayOf(
Genres._ID, // 0
@ -81,116 +67,212 @@ class MusicLoader(private val app: Application) {
null, null,
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 ->
// Don't even bother running if there's nothing to process.
if (cursor.count == 0) {
return
}
val idIndex = cursor.getColumnIndexOrThrow(Genres._ID)
val nameIndex = cursor.getColumnIndexOrThrow(Genres.NAME)
while (cursor.moveToNext()) {
genres.add(
Pair<Long, String>(
cursor.getLong(idIndex),
cursor.getString(nameIndex)
)
)
val id = cursor.getLong(idIndex)
var name = 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()
}
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 indexMusic() {
Log.i(this::class.simpleName, "Starting music search...")
private fun loadArtists() {
Log.d(this::class.simpleName, "Starting artist search...")
// So, android has a brain aneurysm if you try to get an audio genre through
// AudioColumns. As a result, I just index every genre's name & ID, create a cursor
// 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
// To associate artists with their genres, a new cursor is
// created with all the artists of that type.
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 ->
// Don't run the more expensive file loading operations if there
// 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)
artistCursor?.use { cursor ->
val idIndex = cursor.getColumnIndexOrThrow(Artists._ID)
val nameIndex = cursor.getColumnIndexOrThrow(Artists.ARTIST)
while (cursor.moveToNext()) {
val id = cursor.getLong(idIndex)
val display = cursor.getString(displayIndex)
val name = cursor.getString(nameIndex)
// Get the basic metadata from the cursor
val title = cursor.getString(titleIndex) ?: display
// If an artist has already been added [Which is very likely due to how genres
// 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 album = cursor.getString(albumIndex)
val year = cursor.getInt(yearIndex)
val track = cursor.getInt(trackIndex)
val duration = cursor.getLong(durationIndex)
val numSongs = cursor.getInt(numIndex)
// TODO: Add album art [But its loaded separately, as that will take a bit]
// TODO: Add genres whenever android hasn't borked it
songs.add(
Song(
id, title, artist, album,
genre.second, year, track, duration
albums.add(
Album(
id, name, artist,
year, numSongs
)
)
}
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
suspend fun init(app: Application): MusicLoaderResponse {
Log.i(this::class.simpleName, "Starting initial music load")
val loader = MusicLoader(app)
if (loader.response == MusicLoaderResponse.DONE) {
// If the loading succeeds, then process the songs into lists of
// songs, albums, and artists on the main thread.
withContext(Dispatchers.Main) {
mSongs.value = processSongs(loader.songs)
mAlbums.value = sortIntoAlbums(mSongs.value as MutableList<Song>)
mArtists.value = sortIntoArtists(mAlbums.value as MutableList<Album>)
val sorter = MusicSorter(
loader.artists,
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
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
var artist: String? = null
var year: Int = 0
lateinit var artist: Artist
init {
// 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 }
}
val songs = mutableListOf<Song>()
}

View file

@ -2,19 +2,10 @@ package org.oxycblt.auxio.music.models
// Abstraction for mAlbums
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
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 }
}
val albums = mutableListOf<Album>()
var numAlbums = 0
}

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.
data class Song(
val id: Long,
val name: String?,
val artist: String?,
val album: String?,
val genre: String?,
val year: Int,
val title: String,
val albumName: String,
val track: Int,
val duration: Long,
val coverData: ByteArray? = null
)
val duration: Long
) {
lateinit var album: Album
}