Improve music loading system

Heavily improve the music loading system, hopefully fixing some edge-cases that people have been reporting.
This commit is contained in:
OxygenCobalt 2021-02-20 11:56:31 -07:00
parent 00e7af8f3d
commit cca80c65eb
No known key found for this signature in database
GPG key ID: 37DBE3621FE9AD47
6 changed files with 173 additions and 189 deletions

View file

@ -52,7 +52,6 @@ class MainActivity : AppCompatActivity() {
// Since the activity is set to singleInstance [Given that there's only MainActivity]
// We have to manually push the intent whenever we get one so that the fragments
// can catch any file intents
// FIXME: Centralize the file intent code in MainActivity, if thats even possible
setIntent(intent)
}

View file

@ -45,34 +45,20 @@ data class Song(
private var mGenre: Genre? = null
val genre: Genre? get() = mGenre
val album: Album get() {
val album = mAlbum
val album: Album get() = requireNotNull(mAlbum)
if (album != null) {
return album
} else {
error("Song $name must have an album")
}
}
/**
* Apply a genre to a song.
*/
fun applyGenre(genre: Genre) {
if (mGenre == null) {
mGenre = genre
}
}
/**
* Apply an album to a song.
*/
fun applyAlbum(album: Album) {
fun linkAlbum(album: Album) {
if (mAlbum == null) {
mAlbum = album
}
}
fun linkGenre(genre: Genre) {
if (mGenre == null) {
mGenre = genre
}
}
val seconds = duration / 1000
val formattedDuration: String = seconds.toDuration()
}
@ -95,37 +81,23 @@ data class Album(
val year: Int = 0
) : Parent() {
private var mArtist: Artist? = null
val artist: Artist get() {
val artist = mArtist
if (artist != null) {
return artist
} else {
error("Album $name must have an artist")
}
}
val artist: Artist get() = requireNotNull(mArtist)
private val mSongs = mutableListOf<Song>()
val songs: List<Song> get() = mSongs
val totalDuration: String by lazy {
var seconds: Long = 0
songs.forEach {
seconds += it.seconds
}
seconds.toDuration()
}
val totalDuration: String get() = songs.sumOf { it.seconds }.toDuration()
fun applySongs(songs: List<Song>) {
songs.forEach {
it.applyAlbum(this)
mSongs.add(it)
}
}
fun applyArtist(artist: Artist) {
fun linkArtist(artist: Artist) {
mArtist = artist
}
fun linkSongs(songs: List<Song>) {
for (song in songs) {
song.linkAlbum(this)
mSongs.add(song)
}
}
}
/**
@ -141,25 +113,17 @@ data class Artist(
val albums: List<Album>
) : Parent() {
init {
albums.forEach {
it.applyArtist(this)
albums.forEach { album ->
album.linkArtist(this)
}
}
val genre: Genre? by lazy {
val groupedGenres = songs.groupBy { it.genre }
groupedGenres.keys.maxByOrNull { key ->
groupedGenres[key]?.size ?: 0
}
songs.map { it.genre }.maxByOrNull { it?.songs?.size ?: 0 }
}
val songs: List<Song> by lazy {
val songs = mutableListOf<Song>()
albums.forEach {
songs.addAll(it.songs)
}
songs
albums.flatMap { it.songs }
}
}
@ -184,17 +148,12 @@ data class Genre(
}
}
val totalDuration: String by lazy {
var seconds: Long = 0
songs.forEach {
seconds += it.seconds
}
seconds.toDuration()
}
val totalDuration: String get() =
songs.sumOf { it.seconds }.toDuration()
fun addSong(song: Song) {
fun linkSong(song: Song) {
mSongs.add(song)
song.applyGenre(this)
song.linkGenre(this)
}
}

View file

@ -7,8 +7,10 @@ import android.provider.OpenableColumns
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.oxycblt.auxio.logD
import org.oxycblt.auxio.logE
import org.oxycblt.auxio.music.processing.MusicLinker
import org.oxycblt.auxio.music.processing.MusicLoader
import org.oxycblt.auxio.music.processing.MusicSorter
import java.lang.Exception
/**
* The main storage for music items. Use [MusicStore.getInstance] to get the single instance of it.
@ -50,28 +52,35 @@ class MusicStore private constructor() {
val start = System.currentTimeMillis()
val loader = MusicLoader(app)
val response = loader.loadMusic()
try {
val loader = MusicLoader(app)
loader.loadMusic()
if (response == Response.SUCCESS) {
// If the loading succeeds, then sort the songs and update the value
val sorter = MusicSorter(loader.songs, loader.albums)
if (loader.songs.isEmpty()) {
return@withContext Response.NO_MUSIC
}
sorter.sort()
val linker = MusicLinker(app, loader.songs, loader.albums, loader.genres)
linker.link()
mSongs = sorter.songs.toList()
mAlbums = sorter.albums.toList()
mArtists = sorter.artists.toList()
mGenres = loader.genres.toList()
val elapsed = System.currentTimeMillis() - start
this@MusicStore.logD("Music load completed successfully in ${elapsed}ms.")
mSongs = linker.songs.toList()
mAlbums = linker.albums.toList()
mArtists = linker.artists.toList()
mGenres = linker.genres.toList()
loaded = true
this@MusicStore.logD(
"Music load completed successfully in ${System.currentTimeMillis() - start}ms."
)
} catch (e: Exception) {
logE("Something went horribly wrong.")
logE(e.stackTraceToString())
return@withContext Response.FAILED
}
response
return@withContext Response.SUCCESS
}
}

View file

@ -0,0 +1,116 @@
package org.oxycblt.auxio.music.processing
import android.content.Context
import android.provider.MediaStore.Audio.Genres
import org.oxycblt.auxio.R
import org.oxycblt.auxio.logD
import org.oxycblt.auxio.music.Album
import org.oxycblt.auxio.music.Artist
import org.oxycblt.auxio.music.Genre
import org.oxycblt.auxio.music.Song
/**
* Object that links music data to one-another,
*/
class MusicLinker(
private val context: Context,
val songs: MutableList<Song>,
val albums: MutableList<Album>,
val genres: MutableList<Genre>
) {
private val resolver = context.contentResolver
val artists = mutableListOf<Artist>()
fun link() {
linkAlbums()
linkArtists()
linkGenres()
}
private fun linkAlbums() {
logD("Linking albums")
// Group up songs by their album ids and then link them with their albums
val songsByAlbum = songs.groupBy { it.albumId }
val unknownAlbum = Album(
name = context.getString(R.string.placeholder_album),
artistName = context.getString(R.string.placeholder_artist)
)
songsByAlbum.forEach { entry ->
(albums.find { it.id == entry.key } ?: unknownAlbum).linkSongs(entry.value)
}
// If something goes horribly wrong and somehow songs are still not linked up by the
// album id, just throw them into an unknown album.
if (unknownAlbum.songs.isNotEmpty()) {
albums.add(unknownAlbum)
}
}
private fun linkArtists() {
logD("Linking artists")
// Group albums up by their artist name, should not result in any null-artist issues
val albumsByArtist = albums.groupBy { it.artistName }
albumsByArtist.forEach { entry ->
artists.add(
Artist(
id = (artists.size + Int.MIN_VALUE).toLong(),
name = entry.key, albums = entry.value
)
)
}
logD("Albums successfully linked into ${artists.size} artists")
}
private fun linkGenres() {
logD("Linking genres")
/*
* Okay, I'm going to go on a bit of a tangent here, but why the hell do I have do this?
* In an ideal world I should just be able to write MediaStore.Media.Audio.GENRE in the
* original song projection and then have it fetch the genre from the database, but no,
* why would ANYONE do that? Instead, I have to manually PROJECT EACH GENRE, get their
* song ids, and then waste CPU cycles REPEATEDLY ITERATING through the songs list
* to LINK SONG WITH THEIR GENRE. I bet the google dev who built this busted system
* feels REALLY happy about the promotion they likely got from rushing out another
* android API that quickly rots from the basic act of existing, because now this quirk
* is immortalized and has to be replicated to be backwards compatible! Thanks for nothing!
*/
genres.forEach { genre ->
val songCursor = resolver.query(
Genres.Members.getContentUri("external", genre.id),
arrayOf(Genres.Members._ID),
null, null, null
)
songCursor?.use { cursor ->
val idIndex = cursor.getColumnIndexOrThrow(Genres.Members._ID)
while (cursor.moveToNext()) {
val id = cursor.getLong(idIndex)
songs.find { it.id == id }?.let { song ->
genre.linkSong(song)
}
}
}
}
// Any songs without genres will be thrown into an unknown genre
val songsWithoutGenres = songs.filter { it.genre == null }
if (songsWithoutGenres.isNotEmpty()) {
val unknownGenre = Genre(name = context.getString(R.string.placeholder_genre))
songsWithoutGenres.forEach { song ->
unknownGenre.linkSong(song)
}
genres.add(unknownGenre)
}
}
}

View file

@ -9,10 +9,9 @@ import android.provider.MediaStore.Audio.Media
import androidx.core.database.getStringOrNull
import org.oxycblt.auxio.R
import org.oxycblt.auxio.logD
import org.oxycblt.auxio.logE
import org.oxycblt.auxio.music.Album
import org.oxycblt.auxio.music.Artist
import org.oxycblt.auxio.music.Genre
import org.oxycblt.auxio.music.MusicStore
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.music.toAlbumArtURI
@ -22,30 +21,16 @@ import org.oxycblt.auxio.music.toAlbumArtURI
*/
class MusicLoader(private val app: Application) {
var genres = mutableListOf<Genre>()
var artists = mutableListOf<Artist>()
var albums = mutableListOf<Album>()
var songs = mutableListOf<Song>()
private val resolver = app.contentResolver
fun loadMusic(): MusicStore.Response {
try {
loadGenres()
loadAlbums()
loadSongs()
} catch (error: Exception) {
val trace = error.stackTraceToString()
logE("Something went horribly wrong.")
logE(trace)
return MusicStore.Response.FAILED
}
if (songs.isEmpty()) {
return MusicStore.Response.NO_MUSIC
}
return MusicStore.Response.SUCCESS
fun loadMusic() {
loadGenres()
loadAlbums()
loadSongs()
}
private fun loadGenres() {
@ -174,46 +159,6 @@ class MusicLoader(private val app: Application) {
it.name to it.albumId to it.track to it.duration
}.toMutableList()
// Then try to associate any genres with their respective songs
// This is stupidly inefficient, but I don't have another choice really.
// Blame the android devs for deciding to design MediaStore this way.
for (genre in genres) {
val songGenreCursor = resolver.query(
Genres.Members.getContentUri("external", genre.id),
arrayOf(Genres.Members._ID),
null, null, null
)
songGenreCursor?.use { cursor ->
val idIndex = cursor.getColumnIndexOrThrow(Genres.Members._ID)
while (cursor.moveToNext()) {
val songId = cursor.getLong(idIndex)
songs.find { it.id == songId }?.let {
genre.addSong(it)
}
}
}
}
/*
// Fix that will group songs w/o genres into an unknown genre
// Currently disabled until it would actually benefit someone, otherwise its just
// a performance deadweight.
val songsWithoutGenres = songs.filter { it.genre == null }
if (songsWithoutGenres.isNotEmpty()) {
val unknownGenre = Genre(name = app.getString(R.string.placeholder_genre))
songsWithoutGenres.forEach {
unknownGenre.addSong(it)
}
genres.add(unknownGenre)
}
*/
logD("Song search finished with ${songs.size} found")
}
}

View file

@ -1,44 +0,0 @@
package org.oxycblt.auxio.music.processing
import org.oxycblt.auxio.music.Album
import org.oxycblt.auxio.music.Artist
import org.oxycblt.auxio.music.Song
/**
* Object responsible for creating [Artist]s from [Album]s and generally sorting everything.
*/
class MusicSorter(
val songs: MutableList<Song>,
val albums: MutableList<Album>,
) {
val artists = mutableListOf<Artist>()
fun sort() {
albums.forEach {
groupSongsIntoAlbum(it)
}
createArtistsFromAlbums(albums)
}
private fun groupSongsIntoAlbum(album: Album) {
album.applySongs(songs.filter { it.albumId == album.id })
}
private fun createArtistsFromAlbums(albums: List<Album>) {
val groupedAlbums = albums.groupBy { it.artistName }
groupedAlbums.forEach {
artists.add(
// Min value is deliberately used to prevent conflicts with the MediaStore
// album & artist IDs. Shouldnt conflict with other negative IDs unless there
// are ~2.147 billion artists.
Artist(
id = (artists.size + Int.MIN_VALUE).toLong(),
name = it.key,
albums = it.value
)
)
}
}
}