diff --git a/app/src/main/java/org/oxycblt/auxio/detail/DetailViewModel.kt b/app/src/main/java/org/oxycblt/auxio/detail/DetailViewModel.kt
index aa789536d..5bde98121 100644
--- a/app/src/main/java/org/oxycblt/auxio/detail/DetailViewModel.kt
+++ b/app/src/main/java/org/oxycblt/auxio/detail/DetailViewModel.kt
@@ -17,9 +17,7 @@
package org.oxycblt.auxio.detail
-import android.app.Application
import androidx.annotation.StringRes
-import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
@@ -44,10 +42,8 @@ import org.oxycblt.auxio.playback.PlaybackSettings
import org.oxycblt.auxio.util.*
/**
- * [AndroidViewModel] that manages the Song, Album, Artist, and Genre detail views. Keeps track of
- * the current item they are showing, sub-data to display, and configuration. Since this ViewModel
- * requires a context, it must be instantiated [AndroidViewModel]'s Factory.
- * @param application [Application] context required to initialize certain information.
+ * [ViewModel] that manages the Song, Album, Artist, and Genre detail views. Keeps track of the
+ * current item they are showing, sub-data to display, and configuration.
* @author Alexander Capehart (OxygenCobalt)
*/
@HiltViewModel
diff --git a/app/src/main/java/org/oxycblt/auxio/music/extractor/ExtractionResult.kt b/app/src/main/java/org/oxycblt/auxio/music/extractor/ExtractionResult.kt
deleted file mode 100644
index 72177d409..000000000
--- a/app/src/main/java/org/oxycblt/auxio/music/extractor/ExtractionResult.kt
+++ /dev/null
@@ -1,31 +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.extractor
-
-/**
- * Represents the result of an extraction operation.
- * @author Alexander Capehart (OxygenCobalt)
- */
-enum class ExtractionResult {
- /** A raw song was successfully extracted from the cache. */
- CACHED,
- /** A raw song was successfully extracted from parsing it's file. */
- PARSED,
- /** A raw song could not be parsed. */
- NONE
-}
diff --git a/app/src/main/java/org/oxycblt/auxio/music/extractor/MediaStoreExtractor.kt b/app/src/main/java/org/oxycblt/auxio/music/extractor/MediaStoreExtractor.kt
index a3e361934..c3e3be70f 100644
--- a/app/src/main/java/org/oxycblt/auxio/music/extractor/MediaStoreExtractor.kt
+++ b/app/src/main/java/org/oxycblt/auxio/music/extractor/MediaStoreExtractor.kt
@@ -27,6 +27,7 @@ import androidx.annotation.RequiresApi
import androidx.core.database.getIntOrNull
import androidx.core.database.getStringOrNull
import java.io.File
+import kotlinx.coroutines.channels.Channel
import org.oxycblt.auxio.music.MusicSettings
import org.oxycblt.auxio.music.library.RealSong
import org.oxycblt.auxio.music.metadata.Date
@@ -47,14 +48,45 @@ import org.oxycblt.auxio.util.logD
* music extraction process and primarily intended for redundancy for files not natively supported
* by [MetadataExtractor]. Solely relying on this is not recommended, as it often produces bad
* metadata.
- * @param context [Context] required to query the media database.
- * @param cacheExtractor [CacheExtractor] implementation for cache optimizations.
* @author Alexander Capehart (OxygenCobalt)
*/
-abstract class MediaStoreExtractor(
- private val context: Context,
- private val cacheExtractor: CacheExtractor
-) {
+interface MediaStoreExtractor {
+ /**
+ * Query the media database, initializing this instance in the process.
+ * @return The new [Cursor] returned by the media databases.
+ */
+ suspend fun query(): Cursor
+
+ /**
+ * Consume the [Cursor] loaded after [query].
+ * @param cache A [MetadataCache] used to avoid extracting metadata for cached songs, or null if
+ * no [MetadataCache] was available.
+ * @param incompleteSongs A channel where songs that could not be retrieved from the
+ * [MetadataCache] should be sent to.
+ * @param completeSongs A channel where completed songs should be sent to.
+ */
+ suspend fun consume(
+ cache: MetadataCache?,
+ incompleteSongs: Channel,
+ completeSongs: Channel
+ )
+
+ companion object {
+ /**
+ * Create a framework-backed instance.
+ * @param context [Context] required.
+ * @return A new [RealMediaStoreExtractor] that will work best on the device's API level.
+ */
+ fun from(context: Context): MediaStoreExtractor =
+ when {
+ Build.VERSION.SDK_INT >= Build.VERSION_CODES.R -> Api30MediaStoreExtractor(context)
+ Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q -> Api29MediaStoreExtractor(context)
+ else -> Api21MediaStoreExtractor(context)
+ }
+ }
+}
+
+private abstract class RealMediaStoreExtractor(private val context: Context) : MediaStoreExtractor {
private var cursor: Cursor? = null
private var idIndex = -1
private var titleIndex = -1
@@ -78,14 +110,8 @@ abstract class MediaStoreExtractor(
protected var volumes = listOf()
private set
- /**
- * Initialize this instance. This involves setting up the required sub-extractors and querying
- * the media database for music files.
- * @return A [Cursor] of the music data returned from the database.
- */
- open suspend fun init(): Cursor {
+ override suspend fun query(): Cursor {
val start = System.currentTimeMillis()
- cacheExtractor.init()
val musicSettings = MusicSettings.from(context)
val storageManager = context.getSystemServiceCompat(StorageManager::class)
@@ -190,42 +216,27 @@ abstract class MediaStoreExtractor(
return cursor
}
- /**
- * Finalize the Extractor by writing the newly-loaded [RealSong.Raw]s back into the cache,
- * alongside freeing up memory.
- * @param rawSongs The songs to write into the cache.
- */
- open suspend fun finalize(rawSongs: List) {
- // Free the cursor (and it's resources)
- cursor?.close()
- cursor = null
- cacheExtractor.finalize(rawSongs)
- }
-
- /**
- * Populate a [RealSong.Raw] with the next [Cursor] value provided by [MediaStore].
- * @param raw The [RealSong.Raw] to populate.
- * @return An [ExtractionResult] signifying the result of the operation. Will return
- * [ExtractionResult.CACHED] if [CacheExtractor] returned it.
- */
- fun populate(raw: RealSong.Raw): ExtractionResult {
- val cursor = requireNotNull(cursor) { "MediaStoreLayer is not properly initialized" }
- // Move to the next cursor, stopping if we have exhausted it.
- if (!cursor.moveToNext()) {
- logD("Cursor is exhausted")
- return ExtractionResult.NONE
+ override suspend fun consume(
+ cache: MetadataCache?,
+ incompleteSongs: Channel,
+ completeSongs: Channel
+ ) {
+ val cursor = requireNotNull(cursor) { "Must call query first before running consume" }
+ while (cursor.moveToNext()) {
+ val rawSong = RealSong.Raw()
+ populateFileData(cursor, rawSong)
+ if (cache?.populate(rawSong) == true) {
+ completeSongs.send(rawSong)
+ } else {
+ populateMetadata(cursor, rawSong)
+ incompleteSongs.send(rawSong)
+ }
}
-
- // Populate the minimum required columns to maybe obtain a cache entry.
- populateFileData(cursor, raw)
- if (cacheExtractor.populate(raw) == ExtractionResult.CACHED) {
- // We found a valid cache entry, no need to fully read the entry.
- return ExtractionResult.CACHED
- }
-
- // Could not load entry from cache, we have to read the rest of the metadata.
- populateMetadata(cursor, raw)
- return ExtractionResult.PARSED
+ // Free the cursor and signal that no more incomplete songs will be produced by
+ // this extractor.
+ cursor.close()
+ incompleteSongs.close()
+ this.cursor = null
}
/**
@@ -326,21 +337,6 @@ abstract class MediaStoreExtractor(
}
companion object {
- /**
- * Create a framework-backed instance.
- * @param context [Context] required.
- * @param cacheExtractor [CacheExtractor] to wrap.
- * @return A new [MediaStoreExtractor] that will work best on the device's API level.
- */
- fun from(context: Context, cacheExtractor: CacheExtractor) =
- when {
- Build.VERSION.SDK_INT >= Build.VERSION_CODES.R ->
- Api30MediaStoreExtractor(context, cacheExtractor)
- Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q ->
- Api29MediaStoreExtractor(context, cacheExtractor)
- else -> Api21MediaStoreExtractor(context, cacheExtractor)
- }
-
/**
* The base selector that works across all versions of android. Does not exclude
* directories.
@@ -366,20 +362,12 @@ abstract class MediaStoreExtractor(
// Note: The separation between version-specific backends may not be the cleanest. To preserve
// speed, we only want to add redundancy on known issues, not with possible issues.
-/**
- * A [MediaStoreExtractor] that completes the music loading process in a way compatible from API 21
- * onwards to API 28.
- * @param context [Context] required to query the media database.
- * @param cacheExtractor [CacheExtractor] implementation for cache optimizations.
- * @author Alexander Capehart (OxygenCobalt)
- */
-private class Api21MediaStoreExtractor(context: Context, cacheExtractor: CacheExtractor) :
- MediaStoreExtractor(context, cacheExtractor) {
+private class Api21MediaStoreExtractor(context: Context) : RealMediaStoreExtractor(context) {
private var trackIndex = -1
private var dataIndex = -1
- override suspend fun init(): Cursor {
- val cursor = super.init()
+ override suspend fun query(): Cursor {
+ val cursor = super.query()
// Set up cursor indices for later use.
trackIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.TRACK)
dataIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.DATA)
@@ -447,19 +435,19 @@ private class Api21MediaStoreExtractor(context: Context, cacheExtractor: CacheEx
}
/**
- * A [MediaStoreExtractor] that implements common behavior supported from API 29 onwards.
+ * A [RealMediaStoreExtractor] that implements common behavior supported from API 29 onwards.
* @param context [Context] required to query the media database.
- * @param cacheExtractor [CacheExtractor] implementation for cache optimizations.
+ * @param metadataCacheRepository [MetadataCacheRepository] implementation for cache optimizations.
* @author Alexander Capehart (OxygenCobalt)
*/
@RequiresApi(Build.VERSION_CODES.Q)
-private open class BaseApi29MediaStoreExtractor(context: Context, cacheExtractor: CacheExtractor) :
- MediaStoreExtractor(context, cacheExtractor) {
+private open class BaseApi29MediaStoreExtractor(context: Context) :
+ RealMediaStoreExtractor(context) {
private var volumeIndex = -1
private var relativePathIndex = -1
- override suspend fun init(): Cursor {
- val cursor = super.init()
+ override suspend fun query(): Cursor {
+ val cursor = super.query()
// Set up cursor indices for later use.
volumeIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.VOLUME_NAME)
relativePathIndex =
@@ -509,19 +497,20 @@ private open class BaseApi29MediaStoreExtractor(context: Context, cacheExtractor
}
/**
- * A [MediaStoreExtractor] that completes the music loading process in a way compatible with at API
+ * A [RealMediaStoreExtractor] that completes the music loading process in a way compatible with at
+ * API
* 29.
* @param context [Context] required to query the media database.
- * @param cacheExtractor [CacheExtractor] implementation for cache functionality.
+ * @param metadataCacheRepository [MetadataCacheRepository] implementation for cache functionality.
* @author Alexander Capehart (OxygenCobalt)
*/
@RequiresApi(Build.VERSION_CODES.Q)
-private open class Api29MediaStoreExtractor(context: Context, cacheExtractor: CacheExtractor) :
- BaseApi29MediaStoreExtractor(context, cacheExtractor) {
+private open class Api29MediaStoreExtractor(context: Context) :
+ BaseApi29MediaStoreExtractor(context) {
private var trackIndex = -1
- override suspend fun init(): Cursor {
- val cursor = super.init()
+ override suspend fun query(): Cursor {
+ val cursor = super.query()
// Set up cursor indices for later use.
trackIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.TRACK)
return cursor
@@ -544,20 +533,19 @@ private open class Api29MediaStoreExtractor(context: Context, cacheExtractor: Ca
}
/**
- * A [MediaStoreExtractor] that completes the music loading process in a way compatible from API 30
- * onwards.
+ * A [RealMediaStoreExtractor] that completes the music loading process in a way compatible from API
+ * 30 onwards.
* @param context [Context] required to query the media database.
- * @param cacheExtractor [CacheExtractor] implementation for cache optimizations.
+ * @param metadataCacheRepository [MetadataCacheRepository] implementation for cache optimizations.
* @author Alexander Capehart (OxygenCobalt)
*/
@RequiresApi(Build.VERSION_CODES.R)
-private class Api30MediaStoreExtractor(context: Context, cacheExtractor: CacheExtractor) :
- BaseApi29MediaStoreExtractor(context, cacheExtractor) {
+private class Api30MediaStoreExtractor(context: Context) : BaseApi29MediaStoreExtractor(context) {
private var trackIndex: Int = -1
private var discIndex: Int = -1
- override suspend fun init(): Cursor {
- val cursor = super.init()
+ override suspend fun query(): Cursor {
+ val cursor = super.query()
// Set up cursor indices for later use.
trackIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.CD_TRACK_NUMBER)
discIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.DISC_NUMBER)
diff --git a/app/src/main/java/org/oxycblt/auxio/music/extractor/CacheExtractor.kt b/app/src/main/java/org/oxycblt/auxio/music/extractor/MetadataCacheRepository.kt
similarity index 68%
rename from app/src/main/java/org/oxycblt/auxio/music/extractor/CacheExtractor.kt
rename to app/src/main/java/org/oxycblt/auxio/music/extractor/MetadataCacheRepository.kt
index dcf9ec602..b89e99eaf 100644
--- a/app/src/main/java/org/oxycblt/auxio/music/extractor/CacheExtractor.kt
+++ b/app/src/main/java/org/oxycblt/auxio/music/extractor/MetadataCacheRepository.kt
@@ -36,149 +36,118 @@ import org.oxycblt.auxio.music.parsing.splitEscaped
import org.oxycblt.auxio.util.*
/**
- * Defines an Extractor that can load cached music. This is the first step in the music extraction
- * process and is an optimization to avoid the slow [MediaStoreExtractor] and [MetadataExtractor]
- * extraction process.
+ * A cache of music metadata obtained in prior music loading operations. Obtain an instance with
+ * [MetadataCacheRepository].
* @author Alexander Capehart (OxygenCobalt)
*/
-interface CacheExtractor {
- /** Initialize the Extractor by reading the cache data into memory. */
- suspend fun init()
+interface MetadataCache {
+ /** Whether this cache has encountered a [RealSong.Raw] that did not have a cache entry. */
+ val invalidated: Boolean
/**
- * Finalize the Extractor by writing the newly-loaded [RealSong.Raw]s back into the cache,
- * alongside freeing up memory.
- * @param rawSongs The songs to write into the cache.
+ * Populate a [RealSong.Raw] from a cache entry, if it exists.
+ * @param rawSong The [RealSong.Raw] to populate.
+ * @return true if a cache entry could be applied to [rawSong], false otherwise.
*/
- suspend fun finalize(rawSongs: List)
-
- /**
- * Use the cache to populate the given [RealSong.Raw].
- * @param rawSong The [RealSong.Raw] to attempt to populate. Note that this [RealSong.Raw] will
- * only contain the bare minimum information required to load a cache entry.
- * @return An [ExtractionResult] representing the result of the operation.
- * [ExtractionResult.PARSED] is not returned.
- */
- fun populate(rawSong: RealSong.Raw): ExtractionResult
-
- companion object {
- /**
- * Create an instance with optional read-capacity.
- * @param context [Context] required.
- * @param readable Whether the new [CacheExtractor] should be able to read cached entries.
- * @return A new [CacheExtractor] with the specified configuration.
- */
- fun from(context: Context, readable: Boolean): CacheExtractor =
- if (readable) {
- ReadWriteCacheExtractor(context)
- } else {
- WriteOnlyCacheExtractor(context)
- }
- }
+ fun populate(rawSong: RealSong.Raw): Boolean
}
-/**
- * A [CacheExtractor] only capable of writing to the cache. This can be used to load music with
- * without the cache if the user desires.
- * @param context [Context] required to read the cache database.
- * @see CacheExtractor
- * @author Alexander Capehart (OxygenCobalt)
- */
-private open class WriteOnlyCacheExtractor(private val context: Context) : CacheExtractor {
- protected val cacheDao: CacheDao by lazy { CacheDatabase.getInstance(context).cacheDao() }
-
- override suspend fun init() {
- // Nothing to do.
- }
-
- override suspend fun finalize(rawSongs: List) {
- try {
- // Still write out whatever data was extracted.
- cacheDao.nukeCache()
- cacheDao.insertCache(rawSongs.map(CachedSong::fromRaw))
- } catch (e: Exception) {
- logE("Unable to save cache database.")
- logE(e.stackTraceToString())
+private class RealMetadataCache(cachedSongs: List) : MetadataCache {
+ private val cacheMap = buildMap {
+ for (cachedSong in cachedSongs) {
+ put(cachedSong.mediaStoreId, cachedSong)
}
}
- override fun populate(rawSong: RealSong.Raw) =
- // Nothing to do.
- ExtractionResult.NONE
-}
-
-/**
- * A [CacheExtractor] that supports reading from and writing to the cache.
- * @param context [Context] required to load
- * @see CacheExtractor
- * @author Alexander Capehart
- */
-private class ReadWriteCacheExtractor(private val context: Context) :
- WriteOnlyCacheExtractor(context) {
- private var cacheMap: Map? = null
- private var invalidate = false
-
- override suspend fun init() {
- try {
- // Faster to load the whole database into memory than do a query on each
- // populate call.
- val cache = cacheDao.readCache()
- cacheMap = buildMap {
- for (cachedSong in cache) {
- put(cachedSong.mediaStoreId, cachedSong)
- }
- }
- } catch (e: Exception) {
- logE("Unable to load cache database.")
- logE(e.stackTraceToString())
- }
- }
-
- override suspend fun finalize(rawSongs: List) {
- cacheMap = null
- // Same some time by not re-writing the cache if we were able to create the entire
- // library from it. If there is even just one song we could not populate from the
- // cache, then we will re-write it.
- if (invalidate) {
- logD("Cache was invalidated during loading, rewriting")
- super.finalize(rawSongs)
- }
- }
-
- override fun populate(rawSong: RealSong.Raw): ExtractionResult {
- val map = cacheMap ?: return ExtractionResult.NONE
+ override var invalidated = false
+ override fun populate(rawSong: RealSong.Raw): Boolean {
// For a cached raw song to be used, it must exist within the cache and have matching
// addition and modification timestamps. Technically the addition timestamp doesn't
// exist, but to safeguard against possible OEM-specific timestamp incoherence, we
// check for it anyway.
- val cachedSong = map[rawSong.mediaStoreId]
+ val cachedSong = cacheMap[rawSong.mediaStoreId]
if (cachedSong != null &&
cachedSong.dateAdded == rawSong.dateAdded &&
cachedSong.dateModified == rawSong.dateModified) {
cachedSong.copyToRaw(rawSong)
- return ExtractionResult.CACHED
+ return true
}
// We could not populate this song. This means our cache is stale and should be
// re-written with newly-loaded music.
- invalidate = true
- return ExtractionResult.NONE
+ invalidated = true
+ return false
+ }
+}
+
+/**
+ * A repository allowing access to cached metadata obtained in prior music loading operations.
+ * @author Alexander Capehart (OxygenCobalt)
+ */
+interface MetadataCacheRepository {
+ /**
+ * Read the current [MetadataCache], if it exists.
+ * @return The stored [MetadataCache], or null if it could not be obtained.
+ */
+ suspend fun readCache(): MetadataCache?
+
+ /**
+ * Write the list of newly-loaded [RealSong.Raw]s to the cache, replacing the prior data.
+ * @param rawSongs The [rawSongs] to write to the cache.
+ */
+ suspend fun writeCache(rawSongs: List)
+
+ companion object {
+ /**
+ * Create a framework-backed instance.
+ * @param context [Context] required.
+ * @return A new instance.
+ */
+ fun from(context: Context): MetadataCacheRepository = RealMetadataCacheRepository(context)
+ }
+}
+
+private class RealMetadataCacheRepository(private val context: Context) : MetadataCacheRepository {
+ private val cachedSongsDao: CachedSongsDao by lazy {
+ MetadataCacheDatabase.getInstance(context).cachedSongsDao()
+ }
+
+ override suspend fun readCache() =
+ try {
+ // Faster to load the whole database into memory than do a query on each
+ // populate call.
+ RealMetadataCache(cachedSongsDao.readSongs())
+ } catch (e: Exception) {
+ logE("Unable to load cache database.")
+ logE(e.stackTraceToString())
+ null
+ }
+
+ override suspend fun writeCache(rawSongs: List) {
+ try {
+ // Still write out whatever data was extracted.
+ cachedSongsDao.nukeSongs()
+ cachedSongsDao.insertSongs(rawSongs.map(CachedSong::fromRaw))
+ } catch (e: Exception) {
+ logE("Unable to save cache database.")
+ logE(e.stackTraceToString())
+ }
}
}
@Database(entities = [CachedSong::class], version = 27, exportSchema = false)
-private abstract class CacheDatabase : RoomDatabase() {
- abstract fun cacheDao(): CacheDao
+private abstract class MetadataCacheDatabase : RoomDatabase() {
+ abstract fun cachedSongsDao(): CachedSongsDao
companion object {
- @Volatile private var INSTANCE: CacheDatabase? = null
+ @Volatile private var INSTANCE: MetadataCacheDatabase? = null
/**
* Get/create the shared instance of this database.
* @param context [Context] required.
*/
- fun getInstance(context: Context): CacheDatabase {
+ fun getInstance(context: Context): MetadataCacheDatabase {
val instance = INSTANCE
if (instance != null) {
return instance
@@ -188,7 +157,7 @@ private abstract class CacheDatabase : RoomDatabase() {
val newInstance =
Room.databaseBuilder(
context.applicationContext,
- CacheDatabase::class.java,
+ MetadataCacheDatabase::class.java,
"auxio_metadata_cache.db")
.fallbackToDestructiveMigration()
.fallbackToDestructiveMigrationFrom(0)
@@ -202,10 +171,10 @@ private abstract class CacheDatabase : RoomDatabase() {
}
@Dao
-private interface CacheDao {
- @Query("SELECT * FROM ${CachedSong.TABLE_NAME}") suspend fun readCache(): List
- @Query("DELETE FROM ${CachedSong.TABLE_NAME}") suspend fun nukeCache()
- @Insert suspend fun insertCache(songs: List)
+private interface CachedSongsDao {
+ @Query("SELECT * FROM ${CachedSong.TABLE_NAME}") suspend fun readSongs(): List
+ @Query("DELETE FROM ${CachedSong.TABLE_NAME}") suspend fun nukeSongs()
+ @Insert suspend fun insertSongs(songs: List)
}
@Entity(tableName = CachedSong.TABLE_NAME)
diff --git a/app/src/main/java/org/oxycblt/auxio/music/extractor/MetadataExtractor.kt b/app/src/main/java/org/oxycblt/auxio/music/extractor/MetadataExtractor.kt
index 1eab15d14..ae63b3024 100644
--- a/app/src/main/java/org/oxycblt/auxio/music/extractor/MetadataExtractor.kt
+++ b/app/src/main/java/org/oxycblt/auxio/music/extractor/MetadataExtractor.kt
@@ -21,7 +21,7 @@ import android.content.Context
import androidx.core.text.isDigitsOnly
import com.google.android.exoplayer2.MediaItem
import com.google.android.exoplayer2.MetadataRetriever
-import kotlinx.coroutines.flow.flow
+import kotlinx.coroutines.channels.Channel
import org.oxycblt.auxio.music.library.RealSong
import org.oxycblt.auxio.music.metadata.Date
import org.oxycblt.auxio.music.metadata.TextTags
@@ -34,86 +34,53 @@ import org.oxycblt.auxio.util.logW
/**
* The extractor that leverages ExoPlayer's [MetadataRetriever] API to parse metadata. This is the
* last step in the music extraction process and is mostly responsible for papering over the bad
- * metadata that [MediaStoreExtractor] produces.
+ * metadata that [RealMediaStoreExtractor] produces.
*
* @param context [Context] required for reading audio files.
- * @param mediaStoreExtractor [MediaStoreExtractor] implementation for cache optimizations and
- * redundancy.
* @author Alexander Capehart (OxygenCobalt)
*/
-class MetadataExtractor(
- private val context: Context,
- private val mediaStoreExtractor: MediaStoreExtractor
-) {
+class MetadataExtractor(private val context: Context) {
// We can parallelize MetadataRetriever Futures to work around it's speed issues,
// producing similar throughput's to other kinds of manual metadata extraction.
private val taskPool: Array = arrayOfNulls(TASK_CAPACITY)
- /**
- * Initialize this extractor. This actually initializes the sub-extractors that this instance
- * relies on.
- * @return The amount of music that is expected to be loaded.
- */
- suspend fun init() = mediaStoreExtractor.init().count
-
- /**
- * Finalize the Extractor by writing the newly-loaded [RealSong.Raw]s back into the cache,
- * alongside freeing up memory.
- * @param rawSongs The songs to write into the cache.
- */
- suspend fun finalize(rawSongs: List) = mediaStoreExtractor.finalize(rawSongs)
-
- /**
- * Returns a flow that parses all [RealSong.Raw] instances queued by the sub-extractors. This
- * will first delegate to the sub-extractors before parsing the metadata itself.
- * @return A flow of [RealSong.Raw] instances.
- */
- fun extract() = flow {
- while (true) {
- val raw = RealSong.Raw()
- when (mediaStoreExtractor.populate(raw)) {
- ExtractionResult.NONE -> break
- ExtractionResult.PARSED -> {}
- ExtractionResult.CACHED -> {
- // Avoid running the expensive parsing process on songs we can already
- // restore from the cache.
- emit(raw)
- continue
- }
- }
-
- // Spin until there is an open slot we can insert a task in.
- spin@ while (true) {
- for (i in taskPool.indices) {
- val task = taskPool[i]
- if (task != null) {
- val finishedRaw = task.get()
- if (finishedRaw != null) {
- emit(finishedRaw)
- taskPool[i] = Task(context, raw)
- break@spin
- }
- } else {
- taskPool[i] = Task(context, raw)
- break@spin
- }
- }
- }
- }
-
+ suspend fun consume(
+ incompleteSongs: Channel,
+ completeSongs: Channel
+ ) {
spin@ while (true) {
- // Spin until all of the remaining tasks are complete.
+ // Spin until there is an open slot we can insert a task in.
for (i in taskPool.indices) {
val task = taskPool[i]
if (task != null) {
- val finishedRaw = task.get() ?: continue@spin
- emit(finishedRaw)
+ completeSongs.send(task.get() ?: continue)
+ }
+ val result = incompleteSongs.tryReceive()
+ if (result.isClosed) {
taskPool[i] = null
+ break@spin
+ }
+ taskPool[i] = result.getOrNull()?.let { Task(context, it) }
+ }
+ }
+
+ do {
+ var ongoingTasks = false
+ for (i in taskPool.indices) {
+ val task = taskPool[i]
+ if (task != null) {
+ val finishedRawSong = task.get()
+ if (finishedRawSong != null) {
+ completeSongs.send(finishedRawSong)
+ taskPool[i] = null
+ } else {
+ ongoingTasks = true
+ }
}
}
+ } while (ongoingTasks)
- break
- }
+ completeSongs.close()
}
private companion object {
@@ -127,7 +94,7 @@ class MetadataExtractor(
* @param raw [RealSong.Raw] to process.
* @author Alexander Capehart (OxygenCobalt)
*/
-class Task(context: Context, private val raw: RealSong.Raw) {
+private class Task(context: Context, private val raw: RealSong.Raw) {
// Note that we do not leverage future callbacks. This is because errors in the
// (highly fallible) extraction process will not bubble up to Indexer when a
// listener is used, instead crashing the app entirely.
@@ -137,6 +104,8 @@ class Task(context: Context, private val raw: RealSong.Raw) {
MediaItem.fromUri(
requireNotNull(raw.mediaStoreId) { "Invalid raw: No id" }.toAudioUri()))
+ init {}
+
/**
* Try to get a completed song from this [Task], if it has finished processing.
* @return A [RealSong.Raw] instance if processing has completed, null otherwise.
diff --git a/app/src/main/java/org/oxycblt/auxio/music/library/RealMusic.kt b/app/src/main/java/org/oxycblt/auxio/music/library/RealMusic.kt
index 057ef3d37..6a993d0aa 100644
--- a/app/src/main/java/org/oxycblt/auxio/music/library/RealMusic.kt
+++ b/app/src/main/java/org/oxycblt/auxio/music/library/RealMusic.kt
@@ -46,6 +46,8 @@ import org.oxycblt.auxio.util.nonZeroOrNull
import org.oxycblt.auxio.util.toUuidOrNull
import org.oxycblt.auxio.util.unlikelyToBeNull
+// TODO: Split off raw music and real music
+
/**
* Library-backed implementation of [RealSong].
* @param raw The [Raw] to derive the member data from.
diff --git a/app/src/main/java/org/oxycblt/auxio/music/system/Indexer.kt b/app/src/main/java/org/oxycblt/auxio/music/system/Indexer.kt
index 2680b6440..5f14733d7 100644
--- a/app/src/main/java/org/oxycblt/auxio/music/system/Indexer.kt
+++ b/app/src/main/java/org/oxycblt/auxio/music/system/Indexer.kt
@@ -22,8 +22,13 @@ import android.content.Context
import android.content.pm.PackageManager
import android.os.Build
import androidx.core.content.ContextCompat
+import java.util.LinkedList
import kotlinx.coroutines.CancellationException
+import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.async
+import kotlinx.coroutines.channels.Channel
+import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.coroutines.yield
import org.oxycblt.auxio.BuildConfig
@@ -93,8 +98,9 @@ interface Indexer {
* @param context [Context] required to load music.
* @param withCache Whether to use the cache or not when loading. If false, the cache will still
* be written, but no cache entries will be loaded into the new library.
+ * @param scope The [CoroutineScope] to run the indexing job in.
*/
- suspend fun index(context: Context, withCache: Boolean)
+ fun index(context: Context, withCache: Boolean, scope: CoroutineScope)
/**
* Request that the music library should be reloaded. This should be used by components that do
@@ -287,26 +293,28 @@ private class RealIndexer : Indexer {
this.listener = null
}
- override suspend fun index(context: Context, withCache: Boolean) {
- val result =
- try {
- val start = System.currentTimeMillis()
- val library = indexImpl(context, withCache)
- logD(
- "Music indexing completed successfully in " +
- "${System.currentTimeMillis() - start}ms")
- Result.success(library)
- } catch (e: CancellationException) {
- // Got cancelled, propagate upwards to top-level co-routine.
- logD("Loading routine was cancelled")
- throw e
- } catch (e: Exception) {
- // Music loading process failed due to something we have not handled.
- logE("Music indexing failed")
- logE(e.stackTraceToString())
- Result.failure(e)
- }
- emitCompletion(result)
+ override fun index(context: Context, withCache: Boolean, scope: CoroutineScope) {
+ scope.launch {
+ val result =
+ try {
+ val start = System.currentTimeMillis()
+ val library = indexImpl(context, withCache, this)
+ logD(
+ "Music indexing completed successfully in " +
+ "${System.currentTimeMillis() - start}ms")
+ Result.success(library)
+ } catch (e: CancellationException) {
+ // Got cancelled, propagate upwards to top-level co-routine.
+ logD("Loading routine was cancelled")
+ throw e
+ } catch (e: Exception) {
+ // Music loading process failed due to something we have not handled.
+ logE("Music indexing failed")
+ logE(e.stackTraceToString())
+ Result.failure(e)
+ }
+ emitCompletion(result)
+ }
}
@Synchronized
@@ -321,55 +329,62 @@ private class RealIndexer : Indexer {
emitIndexing(null)
}
- private suspend fun indexImpl(context: Context, withCache: Boolean): Library {
+ private suspend fun indexImpl(
+ context: Context,
+ withCache: Boolean,
+ scope: CoroutineScope
+ ): Library {
if (ContextCompat.checkSelfPermission(context, Indexer.PERMISSION_READ_AUDIO) ==
PackageManager.PERMISSION_DENIED) {
// No permissions, signal that we can't do anything.
throw Indexer.NoPermissionException()
}
- // Create the chain of extractors. Each extractor builds on the previous and
- // enables version-specific features in order to create the best possible music
- // experience.
- val cacheExtractor = CacheExtractor.from(context, withCache)
- val mediaStoreExtractor = MediaStoreExtractor.from(context, cacheExtractor)
- val metadataExtractor = MetadataExtractor(context, mediaStoreExtractor)
- val rawSongs = loadRawSongs(metadataExtractor).ifEmpty { throw Indexer.NoMusicException() }
- // Build the rest of the music library from the song list. This is much more powerful
- // and reliable compared to using MediaStore to obtain grouping information.
- val buildStart = System.currentTimeMillis()
- val library = Library.from(rawSongs, MusicSettings.from(context))
- logD("Successfully built library in ${System.currentTimeMillis() - buildStart}ms")
- return library
- }
-
- private suspend fun loadRawSongs(metadataExtractor: MetadataExtractor): List {
- logD("Starting indexing process")
- val start = System.currentTimeMillis()
// Start initializing the extractors. Use an indeterminate state, as there is no ETA on
// how long a media database query will take.
emitIndexing(Indexer.Indexing.Indeterminate)
- val total = metadataExtractor.init()
- yield()
- // Note: We use a set here so we can eliminate song duplicates.
- val rawSongs = mutableListOf()
- metadataExtractor.extract().collect { rawSong ->
+ val metadataCacheRepository = MetadataCacheRepository.from(context)
+ val mediaStoreExtractor = MediaStoreExtractor.from(context)
+ val metadataExtractor = MetadataExtractor(context)
+
+ // Do the initial query of the cache and media databases in parallel.
+ val mediaStoreQueryJob = scope.async { mediaStoreExtractor.query() }
+ val cache =
+ if (withCache) {
+ metadataCacheRepository.readCache()
+ } else {
+ null
+ }
+ val total = mediaStoreQueryJob.await().count
+
+ // Now start processing the queried song information in parallel. Songs that can't be
+ // received from the cache are consisted incomplete and pushed to a separate channel
+ // that will eventually be processed into completed raw songs.
+ val completeSongs = Channel(Channel.UNLIMITED)
+ val incompleteSongs = Channel(Channel.UNLIMITED)
+ val mediaStoreJob =
+ scope.async { mediaStoreExtractor.consume(cache, incompleteSongs, completeSongs) }
+ val metadataJob = scope.async { metadataExtractor.consume(incompleteSongs, completeSongs) }
+
+ // Await completed raw songs as they are processed.
+ val rawSongs = LinkedList()
+ for (rawSong in completeSongs) {
rawSongs.add(rawSong)
- // Now we can signal a defined progress by showing how many songs we have
- // loaded, and the projected amount of songs we found in the library
- // (obtained by the extractors)
- yield()
emitIndexing(Indexer.Indexing.Songs(rawSongs.size, total))
}
+ mediaStoreJob.await()
+ metadataJob.await()
- // Finalize the extractors with the songs we have now loaded. There is no ETA
- // on this process, so go back to an indeterminate state.
+ // Successfully loaded the library, now save the cache and create the library in
+ // parallel.
emitIndexing(Indexer.Indexing.Indeterminate)
- metadataExtractor.finalize(rawSongs)
- logD(
- "Successfully loaded ${rawSongs.size} raw songs in ${System.currentTimeMillis() - start}ms")
- return rawSongs
+ val libraryJob =
+ scope.async(Dispatchers.Main) { Library.from(rawSongs, MusicSettings.from(context)) }
+ if (cache == null || cache.invalidated) {
+ metadataCacheRepository.writeCache(rawSongs)
+ }
+ return libraryJob.await()
}
/**
diff --git a/app/src/main/java/org/oxycblt/auxio/music/system/IndexerService.kt b/app/src/main/java/org/oxycblt/auxio/music/system/IndexerService.kt
index 547fb4b01..c368d8d35 100644
--- a/app/src/main/java/org/oxycblt/auxio/music/system/IndexerService.kt
+++ b/app/src/main/java/org/oxycblt/auxio/music/system/IndexerService.kt
@@ -30,7 +30,7 @@ import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
-import kotlinx.coroutines.launch
+import kotlinx.coroutines.cancel
import org.oxycblt.auxio.BuildConfig
import org.oxycblt.auxio.music.MusicRepository
import org.oxycblt.auxio.music.MusicSettings
@@ -119,11 +119,11 @@ class IndexerService : Service(), Indexer.Controller, MusicSettings.Listener {
override fun onStartIndexing(withCache: Boolean) {
if (indexer.isIndexing) {
// Cancel the previous music loading job.
- currentIndexJob?.cancel()
+ indexScope.cancel()
indexer.reset()
}
// Start a new music loading job on a co-routine.
- currentIndexJob = indexScope.launch { indexer.index(this@IndexerService, withCache) }
+ indexer.index(this@IndexerService, withCache, indexScope)
}
override fun onIndexerStateChanged(state: Indexer.State?) {
diff --git a/app/src/main/java/org/oxycblt/auxio/playback/PlaybackViewModel.kt b/app/src/main/java/org/oxycblt/auxio/playback/PlaybackViewModel.kt
index f03354bed..d8ae3e543 100644
--- a/app/src/main/java/org/oxycblt/auxio/playback/PlaybackViewModel.kt
+++ b/app/src/main/java/org/oxycblt/auxio/playback/PlaybackViewModel.kt
@@ -17,7 +17,6 @@
package org.oxycblt.auxio.playback
-import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
@@ -33,7 +32,7 @@ import org.oxycblt.auxio.playback.queue.Queue
import org.oxycblt.auxio.playback.state.*
/**
- * An [AndroidViewModel] that provides a safe UI frontend for the current playback state.
+ * An [ViewModel] that provides a safe UI frontend for the current playback state.
* @author Alexander Capehart (OxygenCobalt)
*/
@HiltViewModel
diff --git a/app/src/main/java/org/oxycblt/auxio/search/SearchViewModel.kt b/app/src/main/java/org/oxycblt/auxio/search/SearchViewModel.kt
index a28f113d9..99d3708f5 100644
--- a/app/src/main/java/org/oxycblt/auxio/search/SearchViewModel.kt
+++ b/app/src/main/java/org/oxycblt/auxio/search/SearchViewModel.kt
@@ -18,7 +18,6 @@
package org.oxycblt.auxio.search
import androidx.annotation.IdRes
-import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
@@ -38,7 +37,7 @@ import org.oxycblt.auxio.playback.PlaybackSettings
import org.oxycblt.auxio.util.logD
/**
- * An [AndroidViewModel] that keeps performs search operations and tracks their results.
+ * An [ViewModel] that keeps performs search operations and tracks their results.
* @author Alexander Capehart (OxygenCobalt)
*/
@HiltViewModel
diff --git a/app/src/main/java/org/oxycblt/auxio/settings/categories/UIPreferenceFragment.kt b/app/src/main/java/org/oxycblt/auxio/settings/categories/UIPreferenceFragment.kt
index 443daff5b..39f727cb8 100644
--- a/app/src/main/java/org/oxycblt/auxio/settings/categories/UIPreferenceFragment.kt
+++ b/app/src/main/java/org/oxycblt/auxio/settings/categories/UIPreferenceFragment.kt
@@ -26,6 +26,10 @@ import org.oxycblt.auxio.settings.ui.WrappedDialogPreference
import org.oxycblt.auxio.ui.UISettings
import org.oxycblt.auxio.util.isNight
+/**
+ * Display preferences.
+ * @author Alexander Capehart (OxygenCobalt)
+ */
class UIPreferenceFragment : BasePreferenceFragment(R.xml.preferences_ui) {
override fun onOpenDialogPreference(preference: WrappedDialogPreference) {
if (preference.key == getString(R.string.set_key_accent)) {
diff --git a/app/src/main/java/org/oxycblt/auxio/ui/UIModule.kt b/app/src/main/java/org/oxycblt/auxio/ui/UIModule.kt
deleted file mode 100644
index 289fd70ce..000000000
--- a/app/src/main/java/org/oxycblt/auxio/ui/UIModule.kt
+++ /dev/null
@@ -1,31 +0,0 @@
-/*
- * Copyright (c) 2023 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.ui
-
-import android.content.Context
-import dagger.Module
-import dagger.hilt.InstallIn
-import dagger.hilt.android.qualifiers.ApplicationContext
-import dagger.hilt.components.SingletonComponent
-import org.oxycblt.auxio.image.ImageSettings
-
-@Module
-@InstallIn(SingletonComponent::class)
-class UIModule {
- fun settings(@ApplicationContext context: Context) = ImageSettings.from(context)
-}