diff --git a/app/src/main/java/org/oxycblt/auxio/music/GenreCompat.kt b/app/src/main/java/org/oxycblt/auxio/music/GenreCompat.kt new file mode 100644 index 000000000..de4d3be3e --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/music/GenreCompat.kt @@ -0,0 +1,38 @@ +package org.oxycblt.auxio.music + +// Compatibility layer to convert old int-based genres to new genres +val ID3_GENRES = arrayOf( + "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) +} diff --git a/app/src/main/java/org/oxycblt/auxio/music/MusicLoader.kt b/app/src/main/java/org/oxycblt/auxio/music/MusicLoader.kt index da3f21c36..eaecfd71e 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/MusicLoader.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/MusicLoader.kt @@ -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() + var artists = mutableListOf() + var albums = mutableListOf() var songs = mutableListOf() - var genres = mutableListOf>() - 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()) { + val id = cursor.getLong(idIndex) + var name = 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( - Pair( - cursor.getLong(idIndex), - cursor.getString(nameIndex) + Genre( + id, name ) ) - - Log.i(this::class.simpleName, cursor.getString(nameIndex)) } 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 - 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) + // 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 } - // 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 + 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 year = cursor.getInt(yearIndex) + val numSongs = cursor.getInt(numIndex) + + 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." + ) } } diff --git a/app/src/main/java/org/oxycblt/auxio/music/MusicRepository.kt b/app/src/main/java/org/oxycblt/auxio/music/MusicRepository.kt index a8c35d25f..b9c280f76 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/MusicRepository.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/MusicRepository.kt @@ -22,15 +22,25 @@ class MusicRepository { val songs: LiveData> 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) - mArtists.value = sortIntoArtists(mAlbums.value as MutableList) + 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.") } } diff --git a/app/src/main/java/org/oxycblt/auxio/music/MusicSorter.kt b/app/src/main/java/org/oxycblt/auxio/music/MusicSorter.kt new file mode 100644 index 000000000..88192e0b7 --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/music/MusicSorter.kt @@ -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, + val albums: MutableList, + val songs: MutableList +) { + 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" + ) + } + } +} diff --git a/app/src/main/java/org/oxycblt/auxio/music/MusicSorting.kt b/app/src/main/java/org/oxycblt/auxio/music/MusicSorting.kt deleted file mode 100644 index 313ac96de..000000000 --- a/app/src/main/java/org/oxycblt/auxio/music/MusicSorting.kt +++ /dev/null @@ -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): MutableList { - // 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): MutableList { - val songsByAlbum = songs.groupBy { it.album } - val albumList = mutableListOf() - - 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): MutableList { - val albumsByArtist = albums.groupBy { it.artist } - val artistList = mutableListOf() - - albumsByArtist.keys.iterator().forEach { artist -> - val artistAlbums = albumsByArtist[artist] - - artistAlbums?.let { - artistList.add( - Artist(artistAlbums) - ) - } - } - - return artistList -} diff --git a/app/src/main/java/org/oxycblt/auxio/music/models/Album.kt b/app/src/main/java/org/oxycblt/auxio/music/models/Album.kt index da6ce8811..6b04f3b55 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/models/Album.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/models/Album.kt @@ -2,30 +2,13 @@ package org.oxycblt.auxio.music.models // Abstraction for Song data class Album( - var songs: List + 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() } diff --git a/app/src/main/java/org/oxycblt/auxio/music/models/Artist.kt b/app/src/main/java/org/oxycblt/auxio/music/models/Artist.kt index 498e8650b..c5c794974 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/models/Artist.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/models/Artist.kt @@ -2,19 +2,10 @@ package org.oxycblt.auxio.music.models // Abstraction for mAlbums data class Artist( - private var albums: List + val id: Long = 0, + val name: String? = null, + val genres: MutableList = 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() + var numAlbums = 0 } diff --git a/app/src/main/java/org/oxycblt/auxio/music/models/Genre.kt b/app/src/main/java/org/oxycblt/auxio/music/models/Genre.kt new file mode 100644 index 000000000..95ac268fb --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/music/models/Genre.kt @@ -0,0 +1,6 @@ +package org.oxycblt.auxio.music.models + +data class Genre( + val id: Long, + val name: String? +) diff --git a/app/src/main/java/org/oxycblt/auxio/music/models/Song.kt b/app/src/main/java/org/oxycblt/auxio/music/models/Song.kt index 918067844..3f4013dd8 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/models/Song.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/models/Song.kt @@ -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 +}