music: clean up loader implementation

Clean up the music loader implementation, removing pre-sorting to make
it a bit more efficent. Instead, sorting is done on indiviual
components.
This commit is contained in:
OxygenCobalt 2021-12-05 17:56:21 -07:00
parent cde3a99f4d
commit 637bcccd51
No known key found for this signature in database
GPG key ID: 37DBE3621FE9AD47
12 changed files with 155 additions and 191 deletions

View file

@ -33,6 +33,7 @@ import org.oxycblt.auxio.music.Album
import org.oxycblt.auxio.music.Artist
import org.oxycblt.auxio.music.Genre
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.ui.Sort
import kotlin.math.min
/**
@ -76,7 +77,10 @@ class ArtistImageFetcher private constructor(
private val artist: Artist,
) : AuxioFetcher() {
override suspend fun fetch(): FetchResult? {
val results = artist.albums.mapAtMost(4) { album ->
val albums = Sort.ByName(true)
.sortAlbums(artist.albums)
val results = albums.mapAtMost(4) { album ->
fetchArt(context, album)
}
@ -100,6 +104,7 @@ class GenreImageFetcher private constructor(
private val genre: Genre,
) : AuxioFetcher() {
override suspend fun fetch(): FetchResult? {
// We don't need to sort here, as the way we
val albums = genre.songs.groupBy { it.album }.keys
val results = albums.mapAtMost(4) { album ->
fetchArt(context, album)

View file

@ -18,7 +18,6 @@
package org.oxycblt.auxio.excluded
import android.content.Intent
import android.net.Uri
import android.os.Bundle
import android.os.Environment
@ -32,14 +31,13 @@ import androidx.core.view.isVisible
import androidx.fragment.app.activityViewModels
import androidx.fragment.app.viewModels
import org.oxycblt.auxio.BuildConfig
import org.oxycblt.auxio.MainActivity
import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.DialogExcludedBinding
import org.oxycblt.auxio.playback.PlaybackViewModel
import org.oxycblt.auxio.ui.LifecycleDialog
import org.oxycblt.auxio.util.hardRestart
import org.oxycblt.auxio.util.logD
import org.oxycblt.auxio.util.showToast
import kotlin.system.exitProcess
/**
* Dialog that manages the currently excluded directories.
@ -150,25 +148,12 @@ class ExcludedDialog : LifecycleDialog() {
private fun saveAndRestart() {
excludedModel.save {
playbackModel.savePlaybackState(requireContext(), ::hardRestart)
playbackModel.savePlaybackState(requireContext()) {
requireContext().hardRestart()
}
}
}
private fun hardRestart() {
logD("Performing hard restart.")
// Instead of having to do a ton of cleanup and horrible code changes
// to restart this application non-destructively, I just restart the UI task [There is only
// one, after all] and then kill the application using exitProcess. Works well enough.
val intent = Intent(requireContext().applicationContext, MainActivity::class.java).setFlags(
Intent.FLAG_ACTIVITY_CLEAR_TASK
)
startActivity(intent)
exitProcess(0)
}
/**
* Get *just* the root path, nothing else is really needed.
*/

View file

@ -1,37 +1,14 @@
/*
* Copyright (c) 2021 Auxio Project
* MusicLoader.kt is part of Auxio.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.oxycblt.auxio.music
import android.annotation.SuppressLint
import android.content.Context
import android.provider.MediaStore
import android.provider.MediaStore.Audio.Genres
import android.provider.MediaStore.Audio.Media
import androidx.core.database.getStringOrNull
import org.oxycblt.auxio.R
import org.oxycblt.auxio.excluded.ExcludedDatabase
import org.oxycblt.auxio.ui.Sort
import org.oxycblt.auxio.util.logD
/**
* This class does pretty much all the black magic required to get a remotely sensible music
* indexing system while still optimizing for time. I would recommend you leave this file now
* This class acts as the base for most the black magic required to get a remotely sensible music
* indexing system while still optimizing for time. I would recommend you leave this module now
* before you lose your sanity trying to understand the hoops I had to jump through for this
* system, but if you really want to stay, here's a debrief on why this code is so awful.
*
@ -90,111 +67,72 @@ import org.oxycblt.auxio.util.logD
*
* @author OxygenCobalt
*/
class MusicLoader(private val context: Context) {
var genres = mutableListOf<Genre>()
var albums = mutableListOf<Album>()
var artists = mutableListOf<Artist>()
var songs = mutableListOf<Song>()
class MusicLoader {
data class Library(
val genres: List<Genre>,
val artists: List<Artist>,
val albums: List<Album>,
val songs: List<Song>
)
private val resolver = context.contentResolver
fun load(context: Context): Library? {
val songs = loadSongs(context)
if (songs.isEmpty()) return null
private var selector = "${Media.IS_MUSIC}=1"
private var args = arrayOf<String>()
val albums = buildAlbums(songs)
val artists = buildArtists(context, albums)
val genres = readGenres(context, songs)
/**
* Begin the loading process.
* Resulting models are pushed to [genres], [artists], [albums], and [songs].
*/
fun load() {
buildSelector()
loadGenres()
loadSongs()
linkGenres()
buildAlbums()
buildArtists()
return Library(
genres,
artists,
albums,
songs
)
}
@Suppress("DEPRECATION")
private fun buildSelector() {
private fun loadSongs(context: Context): List<Song> {
var songs = mutableListOf<Song>()
val blacklistDatabase = ExcludedDatabase.getInstance(context)
val paths = blacklistDatabase.readPaths()
// DATA was deprecated on Android Q, but is set to be un-deprecated in Android 12L
var selector = "${MediaStore.Audio.Media.IS_MUSIC}=1"
val args = mutableListOf<String>()
// DATA was deprecated on Android 10, but is set to be un-deprecated in Android 12L.
// The only reason we'd want to change this is to add external partitions support, but
// that's less efficent and there's no demand for that right now.
for (path in paths) {
selector += " AND ${Media.DATA} NOT LIKE ?"
selector += " AND ${MediaStore.Audio.Media.DATA} NOT LIKE ?"
args += "$path%" // Append % so that the selector properly detects children
}
}
private fun loadGenres() {
logD("Starting genre search...")
// First, get a cursor for every genre in the android system
val genreCursor = resolver.query(
Genres.EXTERNAL_CONTENT_URI,
context.contentResolver.query(
MediaStore.Audio.Media.EXTERNAL_CONTENT_URI,
arrayOf(
Genres._ID, // 0
Genres.NAME // 1
MediaStore.Audio.Media._ID,
MediaStore.Audio.Media.TITLE,
MediaStore.Audio.Media.DISPLAY_NAME,
MediaStore.Audio.Media.ALBUM,
MediaStore.Audio.Media.ALBUM_ID,
MediaStore.Audio.Media.ARTIST,
MediaStore.Audio.Media.ALBUM_ARTIST,
MediaStore.Audio.Media.YEAR,
MediaStore.Audio.Media.TRACK,
MediaStore.Audio.Media.DURATION,
),
null, null,
Genres.DEFAULT_SORT_ORDER
)
// And then process those into Genre objects
genreCursor?.use { cursor ->
val idIndex = cursor.getColumnIndexOrThrow(Genres._ID)
val nameIndex = cursor.getColumnIndexOrThrow(Genres.NAME)
while (cursor.moveToNext()) {
val id = cursor.getLong(idIndex)
// No non-broken genre would be missing a name.
val name = cursor.getStringOrNull(nameIndex) ?: continue
genres.add(Genre(id, name, name.getGenreNameCompat() ?: name))
}
}
logD("Genre search finished with ${genres.size} genres found.")
}
@SuppressLint("InlinedApi")
private fun loadSongs() {
logD("Starting song search...")
val songCursor = resolver.query(
Media.EXTERNAL_CONTENT_URI,
arrayOf(
Media._ID, // 0
Media.TITLE, // 1
Media.DISPLAY_NAME, // 2
Media.ALBUM, // 3
Media.ALBUM_ID, // 4
Media.ARTIST, // 5
Media.ALBUM_ARTIST, // 6
Media.YEAR, // 7
Media.TRACK, // 8
Media.DURATION, // 9
),
selector, args,
Media.DEFAULT_SORT_ORDER
)
songCursor?.use { cursor ->
val idIndex = cursor.getColumnIndexOrThrow(Media._ID)
val titleIndex = cursor.getColumnIndexOrThrow(Media.TITLE)
val fileIndex = cursor.getColumnIndexOrThrow(Media.DISPLAY_NAME)
val albumIndex = cursor.getColumnIndexOrThrow(Media.ALBUM)
val albumIdIndex = cursor.getColumnIndexOrThrow(Media.ALBUM_ID)
val artistIndex = cursor.getColumnIndexOrThrow(Media.ARTIST)
val albumArtistIndex = cursor.getColumnIndexOrThrow(Media.ALBUM_ARTIST)
val yearIndex = cursor.getColumnIndexOrThrow(Media.YEAR)
val trackIndex = cursor.getColumnIndexOrThrow(Media.TRACK)
val durationIndex = cursor.getColumnIndexOrThrow(Media.DURATION)
selector, args.toTypedArray(), null
)?.use { cursor ->
val idIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media._ID)
val titleIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.TITLE)
val fileIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.DISPLAY_NAME)
val albumIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.ALBUM)
val albumIdIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.ALBUM_ID)
val artistIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.ARTIST)
val albumArtistIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.ALBUM_ARTIST)
val yearIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.YEAR)
val trackIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.TRACK)
val durationIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.DURATION)
while (cursor.moveToNext()) {
val id = cursor.getLong(idIndex)
@ -226,20 +164,19 @@ class MusicLoader(private val context: Context) {
it.name to it.albumName to it.artistName to it.track to it.duration
}.toMutableList()
logD("Song search finished with ${songs.size} found")
return songs
}
private fun buildAlbums() {
logD("Linking albums")
private fun buildAlbums(songs: List<Song>): List<Album> {
// Group up songs by their album ids and then link them with their albums
val albums = mutableListOf<Album>()
val songsByAlbum = songs.groupBy { it.albumId }
songsByAlbum.forEach { entry ->
// Rely on the first song in this list for album information.
// Note: This might result in a bad year being used for an album if an album's songs
// have multiple years. This is fixable but is currently omitted for speed.
val song = entry.value[0]
// Use the song with the latest year as our metadata song.
// 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.
val song = requireNotNull(entry.value.maxByOrNull { it.year })
albums.add(
Album(
@ -253,14 +190,12 @@ class MusicLoader(private val context: Context) {
}
albums.removeAll { it.songs.isEmpty() }
albums = Sort.ByName(true).sortAlbums(albums).toMutableList()
logD("Songs successfully linked into ${albums.size} albums")
return albums
}
private fun buildArtists() {
logD("Linking artists")
private fun buildArtists(context: Context, albums: List<Album>): List<Artist> {
val artists = mutableListOf<Artist>()
val albumsByArtist = albums.groupBy { it.artistName }
albumsByArtist.forEach { entry ->
@ -270,8 +205,8 @@ class MusicLoader(private val context: Context) {
entry.key
}
// Because of our hacky album artist system, MediaStore artist IDs are unreliable.
// Therefore we just use the hashCode of the artist name as our ID and move on.
// In most cases, MediaStore artist IDs are unreliable or omitted for speed.
// Use the hashCode of the artist name as our ID and move on.
artists.add(
Artist(
id = entry.key.hashCode().toLong(),
@ -282,36 +217,42 @@ class MusicLoader(private val context: Context) {
)
}
artists = Sort.ByName(true).sortParents(artists).toMutableList()
logD("Albums successfully linked into ${artists.size} artists")
return artists
}
private fun linkGenres() {
logD("Linking genres")
private fun readGenres(context: Context, songs: List<Song>): List<Genre> {
val genres = mutableListOf<Genre>()
genres.forEach { genre ->
val songCursor = resolver.query(
Genres.Members.getContentUri("external", genre.id),
arrayOf(Genres.Members._ID),
null, null, null // Dont even bother blacklisting here as useless iters are less expensive than IO
)
// First, get a cursor for every genre in the android system
val genreCursor = context.contentResolver.query(
MediaStore.Audio.Genres.EXTERNAL_CONTENT_URI,
arrayOf(
MediaStore.Audio.Genres._ID,
MediaStore.Audio.Genres.NAME
),
null, null, null
)
songCursor?.use { cursor ->
val idIndex = cursor.getColumnIndexOrThrow(Genres.Members._ID)
// And then process those into Genre objects
genreCursor?.use { cursor ->
val idIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.Genres._ID)
val nameIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.Genres.NAME)
while (cursor.moveToNext()) {
val id = cursor.getLong(idIndex)
while (cursor.moveToNext()) {
// No non-broken genre would be missing a name.
val id = cursor.getLong(idIndex)
val name = cursor.getStringOrNull(nameIndex) ?: continue
songs.find { it.id == id }?.let { song ->
genre.linkSong(song)
}
}
val genre = Genre(
id, name, name.getGenreNameCompat() ?: name
)
linkGenre(context, genre, songs)
genres.add(genre)
}
}
// Songs that don't have a genre will be thrown into an unknown genre.
val unknownGenre = Genre(
id = Long.MIN_VALUE,
name = MediaStore.UNKNOWN_STRING,
@ -327,5 +268,27 @@ class MusicLoader(private val context: Context) {
if (unknownGenre.songs.isNotEmpty()) {
genres.add(unknownGenre)
}
return genres
}
private fun linkGenre(context: Context, genre: Genre, songs: List<Song>) {
val songCursor = context.contentResolver.query(
MediaStore.Audio.Genres.Members.getContentUri("external", genre.id),
arrayOf(MediaStore.Audio.Genres.Members._ID),
null, null, null // Dont even bother blacklisting here as useless iters are less expensive than IO
)
songCursor?.use { cursor ->
val idIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.Genres.Members._ID)
while (cursor.moveToNext()) {
val id = cursor.getLong(idIndex)
songs.find { it.id == id }?.let { song ->
genre.linkSong(song)
}
}
}
}
}

View file

@ -68,17 +68,13 @@ class MusicStore private constructor() {
try {
val start = System.currentTimeMillis()
val loader = MusicLoader(context)
loader.load()
val loader = MusicLoader()
val library = loader.load(context) ?: return Response.Err(ErrorKind.NO_MUSIC)
if (loader.songs.isEmpty()) {
return Response.Err(ErrorKind.NO_MUSIC)
}
mSongs = loader.songs
mAlbums = loader.albums
mArtists = loader.artists
mGenres = loader.genres
mSongs = library.songs
mAlbums = library.albums
mArtists = library.artists
mGenres = library.genres
logD("Music load completed successfully in ${System.currentTimeMillis() - start}ms.")
} catch (e: Exception) {

View file

@ -33,6 +33,7 @@ import org.oxycblt.auxio.music.MusicParent
import org.oxycblt.auxio.music.MusicStore
import org.oxycblt.auxio.settings.SettingsManager
import org.oxycblt.auxio.ui.DisplayMode
import org.oxycblt.auxio.ui.Sort
import java.text.Normalizer
/**
@ -77,6 +78,7 @@ class SearchViewModel : ViewModel() {
// Searching can be quite expensive, so hop on a co-routine
viewModelScope.launch {
val sort = Sort.ByName(true)
val results = mutableListOf<BaseModel>()
// A filter mode of null means to not filter at all.
@ -84,28 +86,28 @@ class SearchViewModel : ViewModel() {
if (mFilterMode == null || mFilterMode == DisplayMode.SHOW_ARTISTS) {
musicStore.artists.filterByOrNull(query)?.let { artists ->
results.add(Header(-1, HeaderString.Single(R.string.lbl_artists)))
results.addAll(artists)
results.addAll(sort.sortParents(artists))
}
}
if (mFilterMode == null || mFilterMode == DisplayMode.SHOW_ALBUMS) {
musicStore.albums.filterByOrNull(query)?.let { albums ->
results.add(Header(-2, HeaderString.Single(R.string.lbl_albums)))
results.addAll(albums)
results.addAll(sort.sortAlbums(albums))
}
}
if (mFilterMode == null || mFilterMode == DisplayMode.SHOW_GENRES) {
musicStore.genres.filterByOrNull(query)?.let { genres ->
results.add(Header(-3, HeaderString.Single(R.string.lbl_genres)))
results.addAll(genres)
results.addAll(sort.sortParents(genres))
}
}
if (mFilterMode == null || mFilterMode == DisplayMode.SHOW_SONGS) {
musicStore.songs.filterByOrNull(query)?.let { songs ->
results.add(Header(-4, HeaderString.Single(R.string.lbl_songs)))
results.addAll(songs)
results.addAll(sort.sortSongs(songs))
}
}
@ -136,7 +138,7 @@ class SearchViewModel : ViewModel() {
* Shortcut that will run a ignoreCase filter on a list and only return
* a value if the resulting list is empty.
*/
private fun List<Music>.filterByOrNull(value: String): List<BaseModel>? {
private fun <T : Music> List<T>.filterByOrNull(value: String): List<T>? {
val filtered = filter {
// Ensure the name we match with is correct.
val name = if (it is MusicParent) {

View file

@ -30,6 +30,7 @@ import androidx.annotation.StringRes
import androidx.core.content.ContextCompat
import org.oxycblt.auxio.MainActivity
import kotlin.reflect.KClass
import kotlin.system.exitProcess
const val INTENT_REQUEST_CODE = 0xA0A0
@ -83,6 +84,19 @@ fun Context.newMainIntent(): PendingIntent {
)
}
fun Context.hardRestart() {
// Instead of having to do a ton of cleanup and horrible code changes
// to restart this application non-destructively, I just restart the UI task [There is only
// one, after all] and then kill the application using exitProcess. Works well enough.
val intent = Intent(applicationContext, MainActivity::class.java).setFlags(
Intent.FLAG_ACTIVITY_CLEAR_TASK
)
startActivity(intent)
exitProcess(0)
}
/**
* Create a toast using the provided string resource.
*/

View file

@ -55,7 +55,7 @@
<string name="set_theme_night">"Tmavé"</string>
<string name="set_accent">"Barva"</string>
<string name="set_black_mode">"Černé téma"</string>
<string name="setting_black_mode_desc">"Použít kompletně černé tmavé téma"</string>
<string name="set_black_mode_desc">"Použít kompletně černé tmavé téma"</string>
<string name="set_display">"Zobrazení"</string>
<string name="set_show_covers">"Zobrazit barvy alba"</string>
<string name="set_show_covers_desc">"Vypněte pro ušetření paměti"</string>

View file

@ -154,7 +154,7 @@
<string name="lbl_sort_album">Album</string>
<string name="lbl_sort_year">Jahr</string>
<string name="set_black_mode">Schwarzes Thema</string>
<string name="setting_black_mode_desc">Ein schwarzes Thema für das dunkle verwenden</string>
<string name="set_black_mode_desc">Ein schwarzes Thema für das dunkle verwenden</string>
<string name="set_loop_pause">Pause bei Wiederholung</string>
<string name="set_loop_pause_desc">Pausiert, wenn ein Song wiederholt wird</string>
<string name="desc_shuffle">Zufällig an- oder ausschalten</string>

View file

@ -60,7 +60,7 @@
<string name="set_theme_night">Oscuro</string>
<string name="set_accent">Acento</string>
<string name="set_black_mode">Tema negro</string>
<string name="setting_black_mode_desc">Usar tema negro puro</string>
<string name="set_black_mode_desc">Usar tema negro puro</string>
<string name="set_display">Pantalla</string>
<string name="set_show_covers">Mostrar carátula de álbum</string>

View file

@ -65,7 +65,7 @@
<string name="set_theme_night">Dark</string>
<string name="set_accent">Color scheme</string>
<string name="set_black_mode">Black theme</string>
<string name="setting_black_mode_desc">Use a pure-black dark theme</string>
<string name="set_black_mode_desc">Use a pure-black dark theme</string>
<string name="set_display">Display</string>
<string name="set_lib_tabs">Library tabs</string>

View file

@ -24,7 +24,7 @@
app:defaultValue="false"
app:iconSpaceReserved="false"
app:key="KEY_BLACK_THEME"
app:summary="@string/setting_black_mode_desc"
app:summary="@string/set_black_mode_desc"
app:title="@string/set_black_mode" />
</PreferenceCategory>

View file

@ -16,8 +16,7 @@ This is probably caused by one of two reasons:
#### I have a large library and Auxio takes really long to load it!
This is expected since reading media takes awhile, especially with libraries containing 10k songs or more.
I hope to mitigate this in the future by allowing one to customize the music loader to optimize for speed instead of accuracy.
This is expected since reading from the audio database takes awhile, especially with libraries containing 10k songs or more.
#### Why ExoPlayer?