From 2576fb26ba7f84c3ace0e94daaeae49b5c686f67 Mon Sep 17 00:00:00 2001 From: OxygenCobalt Date: Sat, 28 May 2022 13:13:19 -0600 Subject: [PATCH] music: introduce backend system Move out the MediaStoreCompat interface into a full interface called Backend. In preparation for direct metadata parsing, it would be useful to create some kind of object system to properly handle the capabilities of each metadata indexing mode. Backend fulfills that by allowing each object to implement their own query and then loading routine. This system is designed somewhat strangely. This is firstly because the ExoPlayer metadata backend will have to plug in to the original MediaStore backend, so making methods more granular allows the ExoPlayer backend to avoid some of the stupid inefficiencies from the actual MediaStore backend, such as the genre loading process. We also want to separate the steps of loading music in order to more adequately show the current loading process to the user in a future change. --- CHANGELOG.md | 2 +- .../java/org/oxycblt/auxio/IntegerTable.kt | 2 +- .../java/org/oxycblt/auxio/music/Indexer.kt | 523 ------------------ .../java/org/oxycblt/auxio/music/Music.kt | 2 + .../org/oxycblt/auxio/music/MusicStore.kt | 1 + .../oxycblt/auxio/music/indexer/Indexer.kt | 196 +++++++ .../auxio/music/indexer/MediaStoreBackend.kt | 365 ++++++++++++ .../oxycblt/auxio/playback/StyledSeekBar.kt | 22 +- .../playback/system/NotificationComponent.kt | 4 +- .../auxio/playback/system/PlaybackService.kt | 6 +- app/src/main/res/values-ar-rIQ/strings.xml | 2 +- app/src/main/res/values-cs/strings.xml | 2 +- app/src/main/res/values-de/strings.xml | 2 +- app/src/main/res/values-es/strings.xml | 2 +- app/src/main/res/values-it/strings.xml | 2 +- app/src/main/res/values-nl/strings.xml | 2 +- app/src/main/res/values-ru/strings.xml | 2 +- app/src/main/res/values-zh-rCN/strings.xml | 2 +- app/src/main/res/values/strings.xml | 2 +- 19 files changed, 589 insertions(+), 552 deletions(-) delete mode 100644 app/src/main/java/org/oxycblt/auxio/music/Indexer.kt create mode 100644 app/src/main/java/org/oxycblt/auxio/music/indexer/Indexer.kt create mode 100644 app/src/main/java/org/oxycblt/auxio/music/indexer/MediaStoreBackend.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index 30bb7ce4c..64328c39f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,7 +30,7 @@ - Made the layout of album songs more similar to other songs #### Dev/Meta -- Updated translations [Konstantin Tutsch -> German, cccClyde -> Chinese, Gsset -> Russian] +- Updated translations [Konstantin Tutsch -> German, cccClyde -> Chinese, Gsset -> Russian, enricocid -> Italian] - Switched to spotless and ktfmt instead of ktlint - Migrated constants to centralized table - Introduced new RecyclerView framework diff --git a/app/src/main/java/org/oxycblt/auxio/IntegerTable.kt b/app/src/main/java/org/oxycblt/auxio/IntegerTable.kt index 89b101a6b..863b256fd 100644 --- a/app/src/main/java/org/oxycblt/auxio/IntegerTable.kt +++ b/app/src/main/java/org/oxycblt/auxio/IntegerTable.kt @@ -53,7 +53,7 @@ object IntegerTable { const val ITEM_TYPE_QUEUE_SONG = 0xA00E /** "Music playback" Notification code */ - const val NOTIFICATION_CODE = 0xA0A0 + const val PLAYBACK_NOTIFICATION_CODE = 0xA0A0 /** Intent request code */ const val REQUEST_CODE = 0xA0C0 diff --git a/app/src/main/java/org/oxycblt/auxio/music/Indexer.kt b/app/src/main/java/org/oxycblt/auxio/music/Indexer.kt deleted file mode 100644 index e0c48033f..000000000 --- a/app/src/main/java/org/oxycblt/auxio/music/Indexer.kt +++ /dev/null @@ -1,523 +0,0 @@ -/* - * Copyright (c) 2022 Auxio Project - * - * 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 . - */ - -package org.oxycblt.auxio.music - -import android.content.ContentUris -import android.content.Context -import android.database.Cursor -import android.net.Uri -import android.os.Build -import android.provider.MediaStore -import androidx.annotation.RequiresApi -import androidx.core.database.getIntOrNull -import androidx.core.database.getStringOrNull -import org.oxycblt.auxio.music.excluded.ExcludedDatabase -import org.oxycblt.auxio.ui.Sort -import org.oxycblt.auxio.util.contentResolverSafe -import org.oxycblt.auxio.util.logD - -/** - * 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. - * - * MediaStore is not a good API. It is not even a bad API. Calling it a bad API is an insult to - * other bad android APIs, like CoordinatorLayout or InputMethodManager. No. MediaStore is a crime - * against humanity and probably a way to summon Zalgo if you look at it the wrong way. - * - * You think that if you wanted to query a song's genre from a media database, you could just put - * "genre" in the query and it would return it, right? But not with MediaStore! No, that's too - * straightforward for this contract that was dropped on it's head as a baby. So instead, you have - * to query for each genre, query all the songs in each genre, and then iterate through those songs - * to link every song with their genre. This is not documented anywhere, and the O(mom im scared) - * algorithm you have to run to get it working single-handedly DOUBLES Auxio's loading times. At no - * point have the devs considered that this system is absolutely insane, and instead focused on - * adding infuriat- I mean nice proprietary extensions to MediaStore for their own Google Play - * Music, and of course every Google Play Music user knew how great that turned out! - * - * It's not even ergonomics that makes this API bad. It's base implementation is completely borked - * as well. Did you know that MediaStore doesn't accept dates that aren't from ID3v2.3 MP3 files? I - * sure didn't, until I decided to upgrade my music collection to ID3v2.4 and FLAC only to see that - * the metadata parser has a brain aneurysm the moment it stumbles upon a dreaded TRDC or DATE tag. - * Once again, this is because internally android uses an ancient in-house metadata parser to get - * everything indexed, and so far they have not bothered to modernize this parser or even switch it - * to something more powerful like Taglib, not even in Android 12. ID3v2.4 has been around for *21 - * years.* *It can drink now.* All of my what. - * - * Not to mention all the other infuriating quirks. Album artists can't be accessed from the albums - * table, so we have to go for the less efficient "make a big query on all the songs lol" method so - * that songs don't end up fragmented across artists. Pretty much every OEM has added some extension - * or quirk to MediaStore that I cannot reproduce, with some OEMs (COUGHSAMSUNGCOUGH) crippling the - * normal tables so that you're railroaded into their music app. The way I do blacklisting relies on - * a semi-deprecated method, and the supposedly "modern" method is SLOWER and causes even more - * problems since I have to manage databases across version boundaries. Sometimes music will have a - * deformed clone that I can't filter out, sometimes Genres will just break for no reason, and - * sometimes tags encoded in UTF-8 will be interpreted as anything from UTF-16 to Latin-1 to *Shift - * JIS* WHY WHY WHY WHY WHY WHY WHY WHY WHY WHY WHY WHY WHY WHY WHY WHY WHY WHY - * - * Is there anything we can do about it? No. Google has routinely shut down issues that begged - * google to fix glaring issues with MediaStore or to just take the API behind the woodshed and - * shoot it. Largely because they have zero incentive to improve it given how "obscure" local music - * listening is. As a result, some players like Vanilla and VLC just hack their own - * pseudo-MediaStore implementation from their own (better) parsers, but this is both infeasible for - * Auxio due to how incredibly slow it is to get a file handle from the android sandbox AND how much - * harder it is to manage a database of your own media that mirrors the filesystem perfectly. And - * even if I set aside those crippling issues and changed my indexer to that, it would face the even - * larger problem of how google keeps trying to kill the filesystem and force you into their - * ContentResolver API. In the future MediaStore could be the only system we have, which is also the - * day that greenland melts and birthdays stop happening forever. - * - * I'm pretty sure nothing is going to happen and MediaStore will continue to be neglected and - * probably deprecated eventually for a "new" API that just coincidentally excludes music indexing. - * Because go screw yourself for wanting to listen to music you own. Be a good consoomer and listen - * to your AlgoPop StreamMix™. - * - * I wish I was born in the neolithic. - * - * @author OxygenCobalt - */ -object Indexer { - /** - * The album_artist MediaStore field has existed since at least API 21, but until API 30 it was - * a proprietary extension for Google Play Music and was not documented. Since this field - * probably works on all versions Auxio supports, we suppress the warning about using a - * possibly-unsupported constant. - */ - @Suppress("InlinedApi") - private const val AUDIO_COLUMN_ALBUM_ARTIST = MediaStore.Audio.AudioColumns.ALBUM_ARTIST - - fun index(context: Context): MusicStore.Library? { - // Establish the compatibility object to use when loading songs. - val compat = - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { - Api30MediaStoreCompat() - } else { - Api21MediaStoreCompat() - } - - val songs = loadSongs(context, compat) - if (songs.isEmpty()) return null - - val albums = buildAlbums(songs) - val artists = buildArtists(albums) - val genres = readGenres(context, songs) - - // Sanity check: Ensure that all songs are linked up to albums/artists/genres. - for (song in songs) { - if (song._isMissingAlbum || song._isMissingArtist || song._isMissingGenre) { - throw IllegalStateException( - "Found malformed song: ${song.rawName} [" + - "album: ${!song._isMissingAlbum} " + - "artist: ${!song._isMissingArtist} " + - "genre: ${!song._isMissingGenre}]") - } - } - - return MusicStore.Library(genres, artists, albums, songs) - } - - /** - * Does the initial query over the song database, including excluded directory checks. The songs - * returned by this function are **not** well-formed. The companion [buildAlbums], - * [buildArtists], and [readGenres] functions must be called with the returned list so that all - * songs are properly linked up. - */ - private fun loadSongs(context: Context, compat: MediaStoreCompat): List { - val excludedDatabase = ExcludedDatabase.getInstance(context) - var selector = "${MediaStore.Audio.Media.IS_MUSIC}=1" - val args = mutableListOf() - - // Apply the excluded directories by filtering out specific DATA values. - // DATA was deprecated in Android 10, but it was un-deprecated in Android 12L, - // so it's probably okay to use it. The only reason we would want to use - // another method is for external partitions support, but there is no demand for that. - for (path in excludedDatabase.readPaths()) { - selector += " AND ${MediaStore.Audio.Media.DATA} NOT LIKE ?" - args += "$path%" // Append % so that the selector properly detects children - } - - var songs = mutableListOf() - - // Establish the columns that work across all versions of android. - val proj = - mutableListOf( - MediaStore.Audio.AudioColumns._ID, - MediaStore.Audio.AudioColumns.TITLE, - MediaStore.Audio.AudioColumns.DISPLAY_NAME, - MediaStore.Audio.AudioColumns.DURATION, - MediaStore.Audio.AudioColumns.YEAR, - MediaStore.Audio.AudioColumns.ALBUM, - MediaStore.Audio.AudioColumns.ALBUM_ID, - MediaStore.Audio.AudioColumns.ARTIST, - AUDIO_COLUMN_ALBUM_ARTIST, - MediaStore.Audio.AudioColumns.DATA) - - // Get the compat impl to add their version-specific columns. - compat.mutateAudioProjection(proj) - - context.contentResolverSafe - .query( - MediaStore.Audio.Media.EXTERNAL_CONTENT_URI, - proj.toTypedArray(), - selector, - args.toTypedArray(), - null) - ?.use { cursor -> - val idIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns._ID) - val titleIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.TITLE) - val fileIndex = - cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.DISPLAY_NAME) - val durationIndex = - cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.DURATION) - val yearIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.YEAR) - val albumIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.ALBUM) - val albumIdIndex = - cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.ALBUM_ID) - val artistIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.ARTIST) - val albumArtistIndex = cursor.getColumnIndexOrThrow(AUDIO_COLUMN_ALBUM_ARTIST) - val dataIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.DATA) - - while (cursor.moveToNext()) { - val raw = Audio() - - raw.id = cursor.getLong(idIndex) - raw.title = cursor.getString(titleIndex) - - // Try to use the DISPLAY_NAME field to obtain a (probably sane) file name - // from the android system. Once again though, OEM issues get in our way and - // this field isn't available on some platforms. In that case, see if we can - // grok a file name from the DATA field. - raw.displayName = - cursor.getStringOrNull(fileIndex) - ?: cursor - .getStringOrNull(dataIndex) - ?.substringAfterLast('/', MediaStore.UNKNOWN_STRING) - ?: MediaStore.UNKNOWN_STRING - - raw.duration = cursor.getLong(durationIndex) - raw.year = cursor.getIntOrNull(yearIndex) - - raw.album = cursor.getStringOrNull(albumIndex) - raw.albumId = cursor.getLong(albumIdIndex) - - // If the artist field is , make it null. This makes handling the - // insanity of the artist field easier later on. - raw.artist = - cursor.getStringOrNull(artistIndex)?.run { - if (this != MediaStore.UNKNOWN_STRING) { - this - } else { - null - } - } - - raw.albumArtist = cursor.getStringOrNull(albumArtistIndex) - - // Allow the compatibility object to add their fields - compat.populateAudio(cursor, raw) - - songs.add(raw.toSong()) - } - } - - // Deduplicate songs to prevent (most) deformed music clones - songs = - songs - .distinctBy { - it.rawName to - it._mediaStoreAlbumName to - it._mediaStoreArtistName to - it._mediaStoreAlbumArtistName to - it.track to - it.disc to - it.durationMs - } - .toMutableList() - - logD("Successfully loaded ${songs.size} songs") - - return songs - } - - /** - * Group songs up into their respective albums. Instead of using the unreliable album or artist - * databases, we instead group up songs by their *lowercase* artist and album name to create - * albums. This serves two purposes: - * 1. Sometimes artist names can be styled differently, e.g "Rammstein" vs. "RAMMSTEIN". This - * makes sure both of those are resolved into a single artist called "Rammstein" - * 2. Sometimes MediaStore will split album IDs up if the songs differ in format. This ensures - * that all songs are unified under a single album. - * - * This does come with some costs, it's far slower than using the album ID itself, and it may - * result in an unrelated album art being selected depending on the song chosen as the template, - * but it seems to work pretty well. - */ - private fun buildAlbums(songs: List): List { - val albums = mutableListOf() - val songsByAlbum = songs.groupBy { it._albumGroupingId } - - for (entry in songsByAlbum) { - val albumSongs = entry.value - - // 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. - // Note: Normally we could want to use something like maxByWith, but apparently - // that does not exist in the kotlin stdlib yet. - val comparator = Sort.NullableComparator() - var templateSong = albumSongs[0] - for (i in 1..albumSongs.lastIndex) { - val candidate = albumSongs[i] - if (comparator.compare(templateSong.track, candidate.track) < 0) { - templateSong = candidate - } - } - - val albumName = templateSong._mediaStoreAlbumName - val albumYear = templateSong._mediaStoreYear - val albumCoverUri = - ContentUris.withAppendedId( - Uri.parse("content://media/external/audio/albumart"), - templateSong._mediaStoreAlbumId) - val artistName = templateSong._artistGroupingName - - albums.add( - Album( - albumName, - albumYear, - albumCoverUri, - albumSongs, - artistName, - )) - } - - logD("Successfully built ${albums.size} albums") - - return albums - } - - /** - * Group up albums into artists. This also requires a de-duplication step due to some edge cases - * where [buildAlbums] could not detect duplicates. - */ - private fun buildArtists(albums: List): List { - val artists = mutableListOf() - val albumsByArtist = albums.groupBy { it._artistGroupingId } - - for (entry in albumsByArtist) { - val templateAlbum = entry.value[0] - val artistName = - if (templateAlbum._artistGroupingName != MediaStore.UNKNOWN_STRING) { - templateAlbum._artistGroupingName - } else { - null - } - val artistAlbums = entry.value - - artists.add(Artist(artistName, artistAlbums)) - } - - logD("Successfully built ${artists.size} artists") - - return artists - } - - /** - * Read all genres and link them up to the given songs. This is the code that requires me to - * make dozens of useless queries just to link genres up. - */ - private fun readGenres(context: Context, songs: List): List { - val genres = mutableListOf() - - context.contentResolverSafe - .query( - MediaStore.Audio.Genres.EXTERNAL_CONTENT_URI, - arrayOf(MediaStore.Audio.Genres._ID, MediaStore.Audio.Genres.NAME), - null, - null, - null) - ?.use { cursor -> - val idIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.Genres._ID) - val nameIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.Genres.NAME) - - while (cursor.moveToNext()) { - // Genre names can be a normal name, an ID3v2 constant, or null. Normal names - // are resolved as usual, but null values don't make sense and are often junk - // anyway, so we skip genres that have them. - val id = cursor.getLong(idIndex) - val name = cursor.getStringOrNull(nameIndex) ?: continue - val genreSongs = queryGenreSongs(context, id, songs) ?: continue - - genres.add(Genre(name, genreSongs)) - } - } - - val songsWithoutGenres = songs.filter { it._isMissingGenre } - if (songsWithoutGenres.isNotEmpty()) { - // Songs that don't have a genre will be thrown into an unknown genre. - val unknownGenre = Genre(null, songsWithoutGenres) - - genres.add(unknownGenre) - } - - logD("Successfully loaded ${genres.size} genres") - - return genres - } - - /** - * Queries the genre songs for [genreId]. Some genres are insane and don't contain songs for - * some reason, so if that's the case then this function will return null. - */ - private fun queryGenreSongs(context: Context, genreId: Long, songs: List): List? { - val genreSongs = mutableListOf() - - // Don't even bother blacklisting here as useless iterations are less expensive than IO - context.contentResolverSafe - .query( - MediaStore.Audio.Genres.Members.getContentUri("external", genreId), - arrayOf(MediaStore.Audio.Genres.Members._ID), - null, - null, - null) - ?.use { cursor -> - val idIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.Genres.Members._ID) - - while (cursor.moveToNext()) { - val id = cursor.getLong(idIndex) - songs.find { it._mediaStoreId == id }?.let { song -> genreSongs.add(song) } - } - } - - return genreSongs.ifEmpty { null } - } - - /** - * Represents a song as it is represented by MediaStore. This is progressively mutated over - * several steps of the music loading process until it is complete enough to be transformed into - * a song. - * - * TODO: Add manual metadata parsing. - */ - private data class Audio( - var id: Long? = null, - var title: String? = null, - var displayName: String? = null, - var duration: Long? = null, - var track: Int? = null, - var disc: Int? = null, - var year: Int? = null, - var album: String? = null, - var albumId: Long? = null, - var artist: String? = null, - var albumArtist: String? = null, - ) { - fun toSong(): Song = - Song( - requireNotNull(title) { "Malformed song: No title" }, - requireNotNull(displayName) { "Malformed song: No file name" }, - requireNotNull(duration) { "Malformed song: No duration" }, - track, - disc, - requireNotNull(id) { "Malformed song: No song id" }, - year, - requireNotNull(album) { "Malformed song: No album name" }, - requireNotNull(albumId) { "Malformed song: No album id" }, - artist, - albumArtist, - ) - } - - /** A compatibility interface to implement version-specific audio database querying. */ - private interface MediaStoreCompat { - /** Mutate the pre-existing projection with version-specific values. */ - fun mutateAudioProjection(proj: MutableList) - /** Mutate [audio] with the columns added in [mutateAudioProjection], */ - fun populateAudio(cursor: Cursor, audio: Audio) - } - - @RequiresApi(Build.VERSION_CODES.R) - private class Api30MediaStoreCompat : MediaStoreCompat { - private var trackIndex: Int = -1 - private var discIndex: Int = -1 - - override fun mutateAudioProjection(proj: MutableList) { - proj.add(MediaStore.Audio.AudioColumns.CD_TRACK_NUMBER) - proj.add(MediaStore.Audio.AudioColumns.DISC_NUMBER) - } - - override fun populateAudio(cursor: Cursor, audio: Audio) { - if (trackIndex == -1) { - trackIndex = - cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.CD_TRACK_NUMBER) - } - - if (discIndex == -1) { - discIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.DISC_NUMBER) - } - - // Both CD_TRACK_NUMBER and DISC_NUMBER tend to be formatted as they are in - // the tag itself, which is to say that it is formatted as NN/TT tracks, where - // N is the number and T is the total. Parse the number while leaving out the - // total, as we have no use for it. - - cursor - .getStringOrNull(trackIndex) - ?.split('/', limit = 2) - ?.getOrNull(0) - ?.toIntOrNull() - ?.let { audio.track = it } - cursor - .getStringOrNull(discIndex) - ?.split('/', limit = 2) - ?.getOrNull(0) - ?.toIntOrNull() - ?.let { audio.disc = it } - } - } - - private class Api21MediaStoreCompat : MediaStoreCompat { - private var trackIndex: Int = -1 - - override fun mutateAudioProjection(proj: MutableList) { - proj.add(MediaStore.Audio.AudioColumns.TRACK) - } - - override fun populateAudio(cursor: Cursor, audio: Audio) { - if (trackIndex == -1) { - trackIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.TRACK) - } - - // TRACK is formatted as DTTT where D is the disc number and T is the track number. - // At least, I think so. I've so far been unable to reproduce disc numbers on older - // devices. Keep it around just in case. - - val rawTrack = cursor.getIntOrNull(trackIndex) - if (rawTrack != null) { - audio.track = rawTrack % 1000 - - // A disc number of 0 means that there is no disc. - val disc = rawTrack / 1000 - if (disc > 0) { - audio.disc = disc - } - } - } - } -} diff --git a/app/src/main/java/org/oxycblt/auxio/music/Music.kt b/app/src/main/java/org/oxycblt/auxio/music/Music.kt index 65cf60be1..470344db4 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/Music.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/Music.kt @@ -81,6 +81,8 @@ data class Song( val _mediaStoreArtistName: String?, /** Internal field. Do not use. */ val _mediaStoreAlbumArtistName: String?, + /** Internal field. Do not use. */ + val _mediaStoreGenreName: String? ) : Music() { override val id: Long get() { diff --git a/app/src/main/java/org/oxycblt/auxio/music/MusicStore.kt b/app/src/main/java/org/oxycblt/auxio/music/MusicStore.kt index 3f83df869..3a5b292cb 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/MusicStore.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/MusicStore.kt @@ -26,6 +26,7 @@ import androidx.core.content.ContextCompat import java.lang.Exception import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext +import org.oxycblt.auxio.music.indexer.Indexer import org.oxycblt.auxio.util.contentResolverSafe import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.util.logE diff --git a/app/src/main/java/org/oxycblt/auxio/music/indexer/Indexer.kt b/app/src/main/java/org/oxycblt/auxio/music/indexer/Indexer.kt new file mode 100644 index 000000000..899b2d20c --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/music/indexer/Indexer.kt @@ -0,0 +1,196 @@ +/* + * Copyright (c) 2022 Auxio Project + * + * 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 . + */ + +package org.oxycblt.auxio.music.indexer + +import android.content.ContentUris +import android.content.Context +import android.database.Cursor +import android.net.Uri +import android.os.Build +import android.provider.MediaStore +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.ui.Sort +import org.oxycblt.auxio.util.logD + +object Indexer { + fun index(context: Context): MusicStore.Library? { + // Establish the backend to use when initially loading songs. + val backend = + when { + Build.VERSION.SDK_INT >= Build.VERSION_CODES.R -> Api30MediaStoreBackend() + else -> Api21MediaStoreBackend() + } + + val songs = buildSongs(context, backend) + if (songs.isEmpty()) return null + + val albums = buildAlbums(songs) + val artists = buildArtists(albums) + val genres = buildGenres(songs) + + // Sanity check: Ensure that all songs are linked up to albums/artists/genres. + for (song in songs) { + if (song._isMissingAlbum || song._isMissingArtist || song._isMissingGenre) { + throw IllegalStateException( + "Found malformed song: ${song.rawName} [" + + "album: ${!song._isMissingAlbum} " + + "artist: ${!song._isMissingArtist} " + + "genre: ${!song._isMissingGenre}]") + } + } + + return MusicStore.Library(genres, artists, albums, songs) + } + + /** + * Does the initial query over the song database, including excluded directory checks. The songs + * returned by this function are **not** well-formed. The companion [buildAlbums], + * [buildArtists], and [buildGenres] functions must be called with the returned list so that all + * songs are properly linked up. + */ + private fun buildSongs(context: Context, backend: Backend): List { + var songs = backend.query(context).use { cursor -> backend.loadSongs(context, cursor) } + + // Deduplicate songs to prevent (most) deformed music clones + songs = + songs.distinctBy { + it.rawName to + it._mediaStoreAlbumName to + it._mediaStoreArtistName to + it._mediaStoreAlbumArtistName to + it._mediaStoreGenreName to + it.track to + it.disc to + it.durationMs + } + + logD("Successfully loaded ${songs.size} songs") + + return songs + } + + /** + * Group songs up into their respective albums. Instead of using the unreliable album or artist + * databases, we instead group up songs by their *lowercase* artist and album name to create + * albums. This serves two purposes: + * 1. Sometimes artist names can be styled differently, e.g "Rammstein" vs. "RAMMSTEIN". This + * makes sure both of those are resolved into a single artist called "Rammstein" + * 2. Sometimes MediaStore will split album IDs up if the songs differ in format. This ensures + * that all songs are unified under a single album. + * + * This does come with some costs, it's far slower than using the album ID itself, and it may + * result in an unrelated album art being selected depending on the song chosen as the template, + * but it seems to work pretty well. + */ + private fun buildAlbums(songs: List): List { + val albums = mutableListOf() + val songsByAlbum = songs.groupBy { it._albumGroupingId } + + for (entry in songsByAlbum) { + val albumSongs = entry.value + + // 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. + // Note: Normally we could want to use something like maxByWith, but apparently + // that does not exist in the kotlin stdlib yet. + val comparator = Sort.NullableComparator() + var templateSong = albumSongs[0] + for (i in 1..albumSongs.lastIndex) { + val candidate = albumSongs[i] + if (comparator.compare(templateSong.track, candidate.track) < 0) { + templateSong = candidate + } + } + + val albumName = templateSong._mediaStoreAlbumName + val albumYear = templateSong._mediaStoreYear + val albumCoverUri = + ContentUris.withAppendedId( + Uri.parse("content://media/external/audio/albumart"), + templateSong._mediaStoreAlbumId) + val artistName = templateSong._artistGroupingName + + albums.add( + Album( + albumName, + albumYear, + albumCoverUri, + albumSongs, + artistName, + )) + } + + logD("Successfully built ${albums.size} albums") + + return albums + } + + /** + * Group up albums into artists. This also requires a de-duplication step due to some edge cases + * where [buildAlbums] could not detect duplicates. + */ + private fun buildArtists(albums: List): List { + val artists = mutableListOf() + val albumsByArtist = albums.groupBy { it._artistGroupingId } + + for (entry in albumsByArtist) { + val templateAlbum = entry.value[0] + val artistName = + if (templateAlbum._artistGroupingName != MediaStore.UNKNOWN_STRING) { + templateAlbum._artistGroupingName + } else { + null + } + val artistAlbums = entry.value + + artists.add(Artist(artistName, artistAlbums)) + } + + logD("Successfully built ${artists.size} artists") + + return artists + } + + /** + * Read all genres and link them up to the given songs. This is the code that requires me to + * make dozens of useless queries just to link genres up. + */ + private fun buildGenres(songs: List): List { + val genres = mutableListOf() + val songsByGenre = songs.groupBy { it._mediaStoreGenreName?.hashCode() } + + for (entry in songsByGenre) { + val templateSong = entry.value[0] + genres.add(Genre(templateSong._mediaStoreGenreName, entry.value)) + } + + logD("Successfully built ${genres.size} genres") + + return genres + } + + interface Backend { + fun query(context: Context): Cursor + fun loadSongs(context: Context, cursor: Cursor): Collection + } +} diff --git a/app/src/main/java/org/oxycblt/auxio/music/indexer/MediaStoreBackend.kt b/app/src/main/java/org/oxycblt/auxio/music/indexer/MediaStoreBackend.kt new file mode 100644 index 000000000..683e6fab0 --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/music/indexer/MediaStoreBackend.kt @@ -0,0 +1,365 @@ +/* + * Copyright (c) 2022 Auxio Project + * + * 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 . + */ + +package org.oxycblt.auxio.music.indexer + +import android.content.Context +import android.database.Cursor +import android.os.Build +import android.provider.MediaStore +import androidx.annotation.RequiresApi +import androidx.core.database.getIntOrNull +import androidx.core.database.getStringOrNull +import org.oxycblt.auxio.music.Song +import org.oxycblt.auxio.music.excluded.ExcludedDatabase +import org.oxycblt.auxio.util.contentResolverSafe + +/* + * 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. + * + * MediaStore is not a good API. It is not even a bad API. Calling it a bad API is an insult to + * other bad android APIs, like CoordinatorLayout or InputMethodManager. No. MediaStore is a crime + * against humanity and probably a way to summon Zalgo if you look at it the wrong way. + * + * You think that if you wanted to query a song's genre from a media database, you could just put + * "genre" in the query and it would return it, right? But not with MediaStore! No, that's too + * straightforward for this contract that was dropped on it's head as a baby. So instead, you have + * to query for each genre, query all the songs in each genre, and then iterate through those songs + * to link every song with their genre. This is not documented anywhere, and the O(mom im scared) + * algorithm you have to run to get it working single-handedly DOUBLES Auxio's loading times. At no + * point have the devs considered that this system is absolutely insane, and instead focused on + * adding infuriat- I mean nice proprietary extensions to MediaStore for their own Google Play + * Music, and of course every Google Play Music user knew how great that turned out! + * + * It's not even ergonomics that makes this API bad. It's base implementation is completely borked + * as well. Did you know that MediaStore doesn't accept dates that aren't from ID3v2.3 MP3 files? I + * sure didn't, until I decided to upgrade my music collection to ID3v2.4 and FLAC only to see that + * the metadata parser has a brain aneurysm the moment it stumbles upon a dreaded TRDC or DATE tag. + * Once again, this is because internally android uses an ancient in-house metadata parser to get + * everything indexed, and so far they have not bothered to modernize this parser or even switch it + * to something more powerful like Taglib, not even in Android 12. ID3v2.4 has been around for *21 + * years.* *It can drink now.* All of my what. + * + * Not to mention all the other infuriating quirks. Album artists can't be accessed from the albums + * table, so we have to go for the less efficient "make a big query on all the songs lol" method so + * that songs don't end up fragmented across artists. Pretty much every OEM has added some extension + * or quirk to MediaStore that I cannot reproduce, with some OEMs (COUGHSAMSUNGCOUGH) crippling the + * normal tables so that you're railroaded into their music app. The way I do blacklisting relies on + * a semi-deprecated method, and the supposedly "modern" method is SLOWER and causes even more + * problems since I have to manage databases across version boundaries. Sometimes music will have a + * deformed clone that I can't filter out, sometimes Genres will just break for no reason, and + * sometimes tags encoded in UTF-8 will be interpreted as anything from UTF-16 to Latin-1 to *Shift + * JIS* WHY WHY WHY WHY WHY WHY WHY WHY WHY WHY WHY WHY WHY WHY WHY WHY WHY WHY + * + * Is there anything we can do about it? No. Google has routinely shut down issues that begged + * google to fix glaring issues with MediaStore or to just take the API behind the woodshed and + * shoot it. Largely because they have zero incentive to improve it given how "obscure" local music + * listening is. As a result, some players like Vanilla and VLC just hack their own + * pseudo-MediaStore implementation from their own (better) parsers, but this is both infeasible for + * Auxio due to how incredibly slow it is to get a file handle from the android sandbox AND how much + * harder it is to manage a database of your own media that mirrors the filesystem perfectly. And + * even if I set aside those crippling issues and changed my indexer to that, it would face the even + * larger problem of how google keeps trying to kill the filesystem and force you into their + * ContentResolver API. In the future MediaStore could be the only system we have, which is also the + * day that greenland melts and birthdays stop happening forever. + * + * I'm pretty sure nothing is going to happen and MediaStore will continue to be neglected and + * probably deprecated eventually for a "new" API that just coincidentally excludes music indexing. + * Because go screw yourself for wanting to listen to music you own. Be a good consoomer and listen + * to your AlgoPop StreamMix™. + * + * I wish I was born in the neolithic. + */ + +abstract class MediaStoreBackend : Indexer.Backend { + private var idIndex = -1 + private var titleIndex = -1 + private var fileIndex = -1 + private var durationIndex = -1 + private var yearIndex = -1 + private var albumIndex = -1 + private var albumIdIndex = -1 + private var artistIndex = -1 + private var albumArtistIndex = -1 + private var dataIndex = -1 + + override fun query(context: Context): Cursor { + val excludedDatabase = ExcludedDatabase.getInstance(context) + var selector = "${MediaStore.Audio.Media.IS_MUSIC}=1" + val args = mutableListOf() + + // Apply the excluded directories by filtering out specific DATA values. + // DATA was deprecated in Android 10, but it was un-deprecated in Android 12L, + // so it's probably okay to use it. The only reason we would want to use + // another method is for external partitions support, but there is no demand for that. + for (path in excludedDatabase.readPaths()) { + selector += " AND ${MediaStore.Audio.Media.DATA} NOT LIKE ?" + args += "$path%" // Append % so that the selector properly detects children + } + + return requireNotNull( + context.contentResolverSafe.query( + MediaStore.Audio.Media.EXTERNAL_CONTENT_URI, + projection, + selector, + args.toTypedArray(), + null)) { "Content resolver failure: No Cursor returned" } + } + + override fun loadSongs(context: Context, cursor: Cursor): Collection { + val audios = mutableListOf