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 9ad64be8e..4c8f408f2 100644
--- a/app/src/main/java/org/oxycblt/auxio/music/MusicStore.kt
+++ b/app/src/main/java/org/oxycblt/auxio/music/MusicStore.kt
@@ -27,10 +27,10 @@ import java.lang.Exception
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.oxycblt.auxio.music.indexer.Indexer
+import org.oxycblt.auxio.music.indexer.useQuery
import org.oxycblt.auxio.util.contentResolverSafe
import org.oxycblt.auxio.util.logD
import org.oxycblt.auxio.util.logE
-import org.oxycblt.auxio.util.useQuery
/**
* The main storage for music items. Getting an instance of this object is more complicated as it
diff --git a/app/src/main/java/org/oxycblt/auxio/music/MusicViewModel.kt b/app/src/main/java/org/oxycblt/auxio/music/MusicViewModel.kt
index 7c1374dd8..0b4ef4660 100644
--- a/app/src/main/java/org/oxycblt/auxio/music/MusicViewModel.kt
+++ b/app/src/main/java/org/oxycblt/auxio/music/MusicViewModel.kt
@@ -25,9 +25,7 @@ import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.launch
import org.oxycblt.auxio.util.logD
-/**
- * A [ViewModel] that represents the current music indexing state.
- */
+/** A [ViewModel] that represents the current music indexing state. */
class MusicViewModel : ViewModel(), MusicStore.Callback {
private val musicStore = MusicStore.getInstance()
@@ -45,7 +43,7 @@ class MusicViewModel : ViewModel(), MusicStore.Callback {
* navigated to and because SnackBars will have the best UX here.
*/
fun loadMusic(context: Context) {
- if (_loaderResponse.value != null || isBusy) {
+ if (musicStore.library != null || isBusy) {
logD("Loader is busy/already completed, not reloading")
return
}
diff --git a/app/src/main/java/org/oxycblt/auxio/music/indexer/ExoPlayerBackend.kt b/app/src/main/java/org/oxycblt/auxio/music/indexer/ExoPlayerBackend.kt
new file mode 100644
index 000000000..3a0232bb9
--- /dev/null
+++ b/app/src/main/java/org/oxycblt/auxio/music/indexer/ExoPlayerBackend.kt
@@ -0,0 +1,183 @@
+/*
+ * 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.provider.MediaStore
+import com.google.android.exoplayer2.MediaItem
+import com.google.android.exoplayer2.MetadataRetriever
+import com.google.android.exoplayer2.metadata.Metadata
+import com.google.android.exoplayer2.metadata.id3.TextInformationFrame
+import com.google.android.exoplayer2.metadata.vorbis.VorbisComment
+import com.google.android.exoplayer2.source.TrackGroupArray
+import com.google.common.util.concurrent.FutureCallback
+import com.google.common.util.concurrent.Futures
+import java.nio.charset.StandardCharsets
+import java.util.concurrent.ConcurrentLinkedQueue
+import java.util.concurrent.Executors
+import java.util.concurrent.Future
+import org.oxycblt.auxio.music.Song
+import org.oxycblt.auxio.util.logW
+
+/**
+ * A [Indexer.Backend] that leverages ExoPlayer's metadata retrieval system to index metadata.
+ *
+ * TODO: This class is currently not used, as there are a number of technical improvements that must
+ * be made first before it is practical.
+ *
+ * @author OxygenCobalt
+ */
+class ExoPlayerBackend(private val inner: MediaStoreBackend) : Indexer.Backend {
+ private val runningTasks: Array?> = arrayOfNulls(TASK_CAPACITY)
+
+ override fun query(context: Context) = inner.query(context)
+
+ override fun loadSongs(context: Context, cursor: Cursor): Collection {
+ // Metadata retrieval with ExoPlayer is asynchronous, so a callback may at any point
+ // add a completed song to the list. To prevent a crash in that case, we use the
+ // concurrent counterpart to a typical mutable list.
+ val songs = ConcurrentLinkedQueue()
+
+ while (cursor.moveToNext()) {
+ val audio = inner.buildAudio(context, cursor)
+
+ val audioUri =
+ ContentUris.withAppendedId(
+ MediaStore.Audio.Media.EXTERNAL_CONTENT_URI, requireNotNull(audio.id))
+
+ while (true) {
+ // Spin until a task slot opens up, then start trying to parse metadata.
+ val idx = runningTasks.indexOfFirst { it == null }
+ if (idx != -1) {
+ val task =
+ MetadataRetriever.retrieveMetadata(context, MediaItem.fromUri(audioUri))
+
+ Futures.addCallback(
+ task, AudioCallback(audio, songs, idx), Executors.newSingleThreadExecutor())
+
+ runningTasks[idx] = task
+
+ break
+ }
+ }
+ }
+
+ while (runningTasks.any { it != null }) {
+ // Spin until all tasks are complete
+ }
+
+ return songs
+ }
+
+ private inner class AudioCallback(
+ private val audio: MediaStoreBackend.Audio,
+ private val dest: ConcurrentLinkedQueue,
+ private val taskIdx: Int
+ ) : FutureCallback {
+ override fun onSuccess(result: TrackGroupArray) {
+ val metadata = result[0].getFormat(0).metadata
+ if (metadata != null) {
+ completeAudio(audio, metadata)
+ } else {
+ logW("No metadata was found for ${audio.title}")
+ }
+
+ dest.add(audio.toSong())
+ runningTasks[taskIdx] = null
+ }
+
+ override fun onFailure(t: Throwable) {
+ logW("Unable to extract metadata for ${audio.title}")
+ logW(t.stackTraceToString())
+ dest.add(audio.toSong())
+ runningTasks[taskIdx] = null
+ }
+ }
+
+ private fun completeAudio(audio: MediaStoreBackend.Audio, metadata: Metadata) {
+ for (i in 0 until metadata.length()) {
+ when (val tag = metadata.get(i)) {
+ // ID3v2 text information frame.
+ is TextInformationFrame ->
+ if (tag.value.isNotEmpty()) {
+ handleId3TextFrame(tag.id, tag.value.sanitize(), audio)
+ }
+ // Vorbis comment. It is assumed that a Metadata can only have vorbis
+ // comments/pictures or ID3 frames, never both.
+ is VorbisComment ->
+ if (tag.value.isNotEmpty()) {
+ handleVorbisComment(tag.key, tag.value.sanitize(), audio)
+ }
+ }
+ }
+ }
+
+ /**
+ * Copies and sanitizes this string under the assumption that it is UTF-8.
+ *
+ * Sometimes ExoPlayer emits weird UTF-8. Worse still, sometimes it emits strings backed by data
+ * allocated by some native function. This could easily cause a terrible crash if you even look
+ * at the malformed string the wrong way.
+ *
+ * This function mitigates it by first encoding the string as UTF-8 bytes (replacing malformed
+ * characters with the replacement in the process), and then re-interpreting it as a new string,
+ * which hopefully fixes encoding sanity while also copying the string out of dodgy native
+ * memory.
+ */
+ private fun String.sanitize() = String(encodeToByteArray(), StandardCharsets.UTF_8)
+
+ private fun handleId3TextFrame(id: String, value: String, audio: MediaStoreBackend.Audio) {
+ // It's assumed that duplicate frames are eliminated by ExoPlayer's metadata parser.
+ when (id) {
+ "TIT2" -> audio.title = value // Title
+ "TRCK" -> value.no?.let { audio.track = it } // Track, as NN/TT
+ "TPOS" -> value.no?.let { audio.disc = it } // Disc, as NN/TT
+ "TYER" -> value.toIntOrNull()?.let { audio.year = it } // ID3v2.3 year, should be digits
+ "TDRC" -> value.iso8601year?.let { audio.year = it } // ID3v2.4 date, parse year field
+ "TALB" -> audio.album = value // Album
+ "TPE1" -> audio.artist = value // Artist
+ "TPE2" -> audio.albumArtist = value // Album artist
+ "TCON" -> audio.genre = value // Genre, with the weird ID3v2 rules
+ }
+ }
+
+ private fun handleVorbisComment(key: String, value: String, audio: MediaStoreBackend.Audio) {
+ // It's assumed that duplicate tags are eliminated by ExoPlayer's metadata parser.
+ when (key) {
+ "TITLE" -> audio.title = value // Title, presumably as NN/TT
+ "TRACKNUMBER" -> value.no?.let { audio.track = it } // Track, presumably as NN/TT
+ "DISCNUMBER" -> value.no?.let { audio.disc = it } // Disc, presumably as NN/TT
+ "DATE" -> value.iso8601year?.let { audio.year = it } // Date, presumably as ISO-8601
+ "ALBUM" -> audio.album = value // Album
+ "ARTIST" -> audio.artist = value // Artist
+ "ALBUMARTIST" -> audio.albumArtist = value // Album artist
+ "GENRE" -> audio.genre = value // Genre, assumed that ID3v2 rules will apply here too.
+ }
+ }
+
+ companion object {
+ /**
+ * The amount of tasks this backend can run efficiently at one time. Eight was chosen here
+ * as higher values made little difference in speed, and lower values generally caused
+ * bottlenecks.
+ */
+ private const val TASK_CAPACITY = 8
+ }
+}
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
index c3a4894b2..9940d97f9 100644
--- a/app/src/main/java/org/oxycblt/auxio/music/indexer/Indexer.kt
+++ b/app/src/main/java/org/oxycblt/auxio/music/indexer/Indexer.kt
@@ -20,7 +20,6 @@ package org.oxycblt.auxio.music.indexer
import android.content.Context
import android.database.Cursor
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
@@ -29,18 +28,44 @@ import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.ui.Sort
import org.oxycblt.auxio.util.logD
+/**
+ * Auxio's media indexer.
+ *
+ * Auxio's media indexer is somewhat complicated, as it has grown to support a variety of use cases
+ * (and hacky garbage) in order to produce the best possible experience. It is split into three
+ * distinct steps:
+ *
+ * 1. Finding a [Backend] to use and then querying the media database with it.
+ * 2. Using the [Backend] and the media data to create songs
+ * 3. Using the songs to build the library, which primarily involves linking up all data objects
+ * with their corresponding parents/children.
+ *
+ * This class in particular handles 3 primarily. For the code that handles 1 and 2, see the other
+ * files in the module.
+ *
+ * @author OxygenCobalt
+ */
object Indexer {
fun index(context: Context): MusicStore.Library? {
// Establish the backend to use when initially loading songs.
- val backend =
+ val mediaStoreBackend =
when {
Build.VERSION.SDK_INT >= Build.VERSION_CODES.R -> Api30MediaStoreBackend()
else -> Api21MediaStoreBackend()
}
- val songs = buildSongs(context, backend)
+ // Disabled until direct parsing is fully capable of integration into Auxio's
+ // architecture.
+ // val backend = if (settingsManager.useQualityMetadata) {
+ // ExoPlayerBackend(mediaStoreBackend)
+ // } else {
+ // mediaStoreBackend
+ // }
+
+ val songs = buildSongs(context, mediaStoreBackend)
if (songs.isEmpty()) return null
+ val buildStart = System.currentTimeMillis()
val albums = buildAlbums(songs)
val artists = buildArtists(albums)
val genres = buildGenres(songs)
@@ -56,17 +81,27 @@ object Indexer {
}
}
+ logD("Successfully built library in ${System.currentTimeMillis() - buildStart}ms")
+
return MusicStore.Library(genres, artists, albums, songs)
}
/**
- * Does the initial query over the song database using [backend]. 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.
+ * Does the initial query over the song database using [backend]. 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) }
+ val queryStart = System.currentTimeMillis()
+ var songs =
+ backend.query(context).use { cursor ->
+ val loadStart = System.currentTimeMillis()
+ logD("Successfully queried media database in ${loadStart - queryStart}ms")
+ val songs = backend.loadSongs(context, cursor)
+ logD("Successfully loaded songs in ${System.currentTimeMillis() - loadStart}ms")
+ songs
+ }
// Deduplicate songs to prevent (most) deformed music clones
songs =
@@ -125,8 +160,8 @@ object Indexer {
rawName = templateSong._albumName,
year = templateSong._year,
albumCoverUri = templateSong._albumCoverUri,
- _artistGroupingName = templateSong._artistGroupingName,
- songs = entry.value))
+ songs = entry.value,
+ _artistGroupingName = templateSong._artistGroupingName))
}
logD("Successfully built ${albums.size} albums")
@@ -146,10 +181,7 @@ object Indexer {
// The first album will suffice for template metadata.
val templateAlbum = entry.value[0]
- artists.add(Artist(
- rawName = templateAlbum._artistGroupingName,
- albums = entry.value
- ))
+ artists.add(Artist(rawName = templateAlbum._artistGroupingName, albums = entry.value))
}
logD("Successfully built ${artists.size} artists")
@@ -157,9 +189,7 @@ object Indexer {
return artists
}
- /**
- * Build genres and link them to their particular songs.
- */
+ /** Build genres and link them to their particular songs. */
private fun buildGenres(songs: List): List {
val genres = mutableListOf()
val songsByGenre = songs.groupBy { it._genreName?.hashCode() }
@@ -174,6 +204,7 @@ object Indexer {
return genres
}
+ /** Represents a backend that metadata can be extracted from. */
interface Backend {
/** Query the media database for an initial cursor. */
fun query(context: Context): Cursor
diff --git a/app/src/main/java/org/oxycblt/auxio/music/indexer/IndexerUtil.kt b/app/src/main/java/org/oxycblt/auxio/music/indexer/IndexerUtil.kt
new file mode 100644
index 000000000..be540d2c2
--- /dev/null
+++ b/app/src/main/java/org/oxycblt/auxio/music/indexer/IndexerUtil.kt
@@ -0,0 +1,50 @@
+/*
+ * 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.ContentResolver
+import android.database.Cursor
+import android.net.Uri
+
+/** Shortcut for making a [ContentResolver] query with less superfluous arguments. */
+fun ContentResolver.queryCursor(
+ uri: Uri,
+ projection: Array,
+ selector: String? = null,
+ args: Array? = null
+) = query(uri, projection, selector, args, null)
+
+/** Shortcut for making a [ContentResolver] query and using the particular cursor with [use]. */
+fun ContentResolver.useQuery(
+ uri: Uri,
+ projection: Array,
+ selector: String? = null,
+ args: Array? = null,
+ block: (Cursor) -> R
+): R? = queryCursor(uri, projection, selector, args)?.use(block)
+
+/**
+ * Parse out the number field from an NN/TT string that is typically found in DISC_NUMBER and
+ * CD_TRACK_NUMBER.
+ */
+val String.no: Int?
+ get() = split('/', limit = 2).getOrNull(0)?.toIntOrNull()
+
+/** Parse out the year field from a (presumably) ISO-8601-like date. */
+val String.iso8601year: Int?
+ get() = split(":", limit = 2).getOrNull(0)?.toIntOrNull()
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
index 98edeaf36..b5bb9307b 100644
--- a/app/src/main/java/org/oxycblt/auxio/music/indexer/MediaStoreBackend.kt
+++ b/app/src/main/java/org/oxycblt/auxio/music/indexer/MediaStoreBackend.kt
@@ -29,8 +29,6 @@ import androidx.core.database.getStringOrNull
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.music.excluded.ExcludedDatabase
import org.oxycblt.auxio.util.contentResolverSafe
-import org.oxycblt.auxio.util.queryCursor
-import org.oxycblt.auxio.util.useQuery
/*
* This file acts as the base for most the black magic required to get a remotely sensible music
@@ -271,17 +269,19 @@ abstract class MediaStoreBackend : Indexer.Backend {
Song(
rawName = requireNotNull(title) { "Malformed audio: No title" },
fileName = requireNotNull(displayName) { "Malformed audio: No file name" },
- uri = ContentUris.withAppendedId(
- MediaStore.Audio.Media.EXTERNAL_CONTENT_URI,
- requireNotNull(id) { "Malformed audio: No song id" }),
+ uri =
+ ContentUris.withAppendedId(
+ MediaStore.Audio.Media.EXTERNAL_CONTENT_URI,
+ requireNotNull(id) { "Malformed audio: No song id" }),
durationMs = requireNotNull(duration) { "Malformed audio: No duration" },
track = track,
disc = disc,
_year = year,
_albumName = requireNotNull(album) { "Malformed song: No album name" },
- _albumCoverUri = ContentUris.withAppendedId(
- EXTERNAL_ALBUM_ART_URI,
- requireNotNull(albumId) { "Malformed song: No album id" }),
+ _albumCoverUri =
+ ContentUris.withAppendedId(
+ EXTERNAL_ALBUM_ART_URI,
+ requireNotNull(albumId) { "Malformed song: No album id" }),
_artistName = artist,
_albumArtistName = albumArtist,
_genreName = genre)
@@ -384,6 +384,7 @@ class Api30MediaStoreBackend : MediaStoreBackend() {
override fun buildAudio(context: Context, cursor: Cursor): Audio {
val audio = super.buildAudio(context, cursor)
+ // Populate our indices if we have not already.
if (trackIndex == -1) {
trackIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.CD_TRACK_NUMBER)
discIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.DISC_NUMBER)
@@ -399,11 +400,4 @@ class Api30MediaStoreBackend : MediaStoreBackend() {
return audio
}
-
- /**
- * Parse out the number field from an NN/TT string that is typically found in DISC_NUMBER and
- * CD_TRACK_NUMBER.
- */
- private val String.no: Int?
- get() = split('/', limit = 2).getOrNull(0)?.toIntOrNull()
}
diff --git a/app/src/main/java/org/oxycblt/auxio/util/FrameworkUtil.kt b/app/src/main/java/org/oxycblt/auxio/util/FrameworkUtil.kt
index 3b741d2dd..01e2a8ad3 100644
--- a/app/src/main/java/org/oxycblt/auxio/util/FrameworkUtil.kt
+++ b/app/src/main/java/org/oxycblt/auxio/util/FrameworkUtil.kt
@@ -17,7 +17,6 @@
package org.oxycblt.auxio.util
-import android.content.ContentResolver
import android.content.Context
import android.content.res.ColorStateList
import android.database.Cursor
@@ -25,7 +24,6 @@ import android.database.sqlite.SQLiteDatabase
import android.graphics.Insets
import android.graphics.Rect
import android.graphics.drawable.Drawable
-import android.net.Uri
import android.os.Build
import android.view.View
import android.view.WindowInsets
@@ -156,23 +154,6 @@ fun Fragment.requireAttached() = check(!isDetached) { "Fragment is detached from
fun SQLiteDatabase.queryAll(tableName: String, block: (Cursor) -> R) =
query(tableName, null, null, null, null, null, null)?.use(block)
-/** Shortcut for making a [ContentResolver] query with less superfluous arguments. */
-fun ContentResolver.queryCursor(
- uri: Uri,
- projection: Array,
- selector: String? = null,
- args: Array? = null
-) = query(uri, projection, selector, args, null)
-
-/** Shortcut for making a [ContentResolver] query and using the particular cursor with [use]. */
-fun ContentResolver.useQuery(
- uri: Uri,
- projection: Array,
- selector: String? = null,
- args: Array? = null,
- block: (Cursor) -> R
-): R? = queryCursor(uri, projection, selector, args)?.use(block)
-
/**
* Resolve system bar insets in a version-aware manner. This can be used to apply padding to a view
* that properly follows all the frustrating changes that were made between Android 8-11.