music: parallelize loading

Parallelize music loading.

- Queries over Media DB and Cache are ran parallel
- MediaStoreExtractor can now continue extracting songs independent
of MetadataExtractor's task pool capacity
- Library and Cache are saved in parallel

Resolves #343.

This should result in some pretty signifigant performance gains
due to the operations now being ran in parallel instead of
sequentially.
This commit is contained in:
Alexander Capehart 2023-02-02 19:22:49 -07:00
parent 78229f4794
commit 1df1d40408
No known key found for this signature in database
GPG key ID: 37DBE3621FE9AD47
12 changed files with 282 additions and 403 deletions

View file

@ -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

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
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
}

View file

@ -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<RealSong.Raw>,
completeSongs: Channel<RealSong.Raw>
)
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<StorageVolume>()
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<RealSong.Raw>) {
// 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<RealSong.Raw>,
completeSongs: Channel<RealSong.Raw>
) {
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)

View file

@ -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<RealSong.Raw>)
/**
* 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<RealSong.Raw>) {
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<CachedSong>) : 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<Long, CachedSong>? = 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<RealSong.Raw>) {
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<RealSong.Raw>)
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<RealSong.Raw>) {
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<CachedSong>
@Query("DELETE FROM ${CachedSong.TABLE_NAME}") suspend fun nukeCache()
@Insert suspend fun insertCache(songs: List<CachedSong>)
private interface CachedSongsDao {
@Query("SELECT * FROM ${CachedSong.TABLE_NAME}") suspend fun readSongs(): List<CachedSong>
@Query("DELETE FROM ${CachedSong.TABLE_NAME}") suspend fun nukeSongs()
@Insert suspend fun insertSongs(songs: List<CachedSong>)
}
@Entity(tableName = CachedSong.TABLE_NAME)

View file

@ -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<Task?> = 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<RealSong.Raw>) = 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<RealSong.Raw>,
completeSongs: Channel<RealSong.Raw>
) {
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.

View file

@ -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.

View file

@ -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<RealSong.Raw> {
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<RealSong.Raw>()
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<RealSong.Raw>(Channel.UNLIMITED)
val incompleteSongs = Channel<RealSong.Raw>(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<RealSong.Raw>()
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()
}
/**

View file

@ -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?) {

View file

@ -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

View file

@ -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

View file

@ -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)) {

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
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)
}