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