music: make extractors injectable

Refactor the music module to make each individual extractor able to be
injected directly.
This commit is contained in:
Alexander Capehart 2023-02-11 14:46:58 -07:00
parent ae0c68c273
commit 6e55801513
No known key found for this signature in database
GPG key ID: 37DBE3621FE9AD47
34 changed files with 564 additions and 469 deletions

View file

@ -32,6 +32,7 @@ import org.oxycblt.auxio.databinding.FragmentDetailBinding
import org.oxycblt.auxio.detail.recycler.AlbumDetailAdapter import org.oxycblt.auxio.detail.recycler.AlbumDetailAdapter
import org.oxycblt.auxio.list.Item import org.oxycblt.auxio.list.Item
import org.oxycblt.auxio.list.ListFragment import org.oxycblt.auxio.list.ListFragment
import org.oxycblt.auxio.list.Sort
import org.oxycblt.auxio.list.adapter.BasicListInstructions import org.oxycblt.auxio.list.adapter.BasicListInstructions
import org.oxycblt.auxio.list.selection.SelectionViewModel import org.oxycblt.auxio.list.selection.SelectionViewModel
import org.oxycblt.auxio.music.Album import org.oxycblt.auxio.music.Album
@ -40,7 +41,6 @@ import org.oxycblt.auxio.music.Music
import org.oxycblt.auxio.music.MusicMode import org.oxycblt.auxio.music.MusicMode
import org.oxycblt.auxio.music.MusicParent import org.oxycblt.auxio.music.MusicParent
import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.list.Sort
import org.oxycblt.auxio.playback.PlaybackViewModel import org.oxycblt.auxio.playback.PlaybackViewModel
import org.oxycblt.auxio.ui.NavigationViewModel import org.oxycblt.auxio.ui.NavigationViewModel
import org.oxycblt.auxio.util.* import org.oxycblt.auxio.util.*

View file

@ -32,6 +32,7 @@ import org.oxycblt.auxio.detail.recycler.ArtistDetailAdapter
import org.oxycblt.auxio.detail.recycler.DetailAdapter import org.oxycblt.auxio.detail.recycler.DetailAdapter
import org.oxycblt.auxio.list.Item import org.oxycblt.auxio.list.Item
import org.oxycblt.auxio.list.ListFragment import org.oxycblt.auxio.list.ListFragment
import org.oxycblt.auxio.list.Sort
import org.oxycblt.auxio.list.adapter.BasicListInstructions import org.oxycblt.auxio.list.adapter.BasicListInstructions
import org.oxycblt.auxio.list.selection.SelectionViewModel import org.oxycblt.auxio.list.selection.SelectionViewModel
import org.oxycblt.auxio.music.Album import org.oxycblt.auxio.music.Album
@ -39,7 +40,6 @@ import org.oxycblt.auxio.music.Artist
import org.oxycblt.auxio.music.Music import org.oxycblt.auxio.music.Music
import org.oxycblt.auxio.music.MusicParent import org.oxycblt.auxio.music.MusicParent
import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.list.Sort
import org.oxycblt.auxio.playback.PlaybackViewModel import org.oxycblt.auxio.playback.PlaybackViewModel
import org.oxycblt.auxio.ui.NavigationViewModel import org.oxycblt.auxio.ui.NavigationViewModel
import org.oxycblt.auxio.util.collect import org.oxycblt.auxio.util.collect

View file

@ -32,12 +32,12 @@ import org.oxycblt.auxio.R
import org.oxycblt.auxio.detail.recycler.SortHeader import org.oxycblt.auxio.detail.recycler.SortHeader
import org.oxycblt.auxio.list.BasicHeader import org.oxycblt.auxio.list.BasicHeader
import org.oxycblt.auxio.list.Item import org.oxycblt.auxio.list.Item
import org.oxycblt.auxio.music.*
import org.oxycblt.auxio.music.library.Library
import org.oxycblt.auxio.list.Sort import org.oxycblt.auxio.list.Sort
import org.oxycblt.auxio.music.*
import org.oxycblt.auxio.music.metadata.AudioInfo import org.oxycblt.auxio.music.metadata.AudioInfo
import org.oxycblt.auxio.music.metadata.Disc import org.oxycblt.auxio.music.metadata.Disc
import org.oxycblt.auxio.music.metadata.ReleaseType import org.oxycblt.auxio.music.metadata.ReleaseType
import org.oxycblt.auxio.music.model.Library
import org.oxycblt.auxio.playback.PlaybackSettings import org.oxycblt.auxio.playback.PlaybackSettings
import org.oxycblt.auxio.util.* import org.oxycblt.auxio.util.*

View file

@ -32,6 +32,7 @@ import org.oxycblt.auxio.detail.recycler.DetailAdapter
import org.oxycblt.auxio.detail.recycler.GenreDetailAdapter import org.oxycblt.auxio.detail.recycler.GenreDetailAdapter
import org.oxycblt.auxio.list.Item import org.oxycblt.auxio.list.Item
import org.oxycblt.auxio.list.ListFragment import org.oxycblt.auxio.list.ListFragment
import org.oxycblt.auxio.list.Sort
import org.oxycblt.auxio.list.adapter.BasicListInstructions import org.oxycblt.auxio.list.adapter.BasicListInstructions
import org.oxycblt.auxio.list.selection.SelectionViewModel import org.oxycblt.auxio.list.selection.SelectionViewModel
import org.oxycblt.auxio.music.Album import org.oxycblt.auxio.music.Album
@ -40,7 +41,6 @@ import org.oxycblt.auxio.music.Genre
import org.oxycblt.auxio.music.Music import org.oxycblt.auxio.music.Music
import org.oxycblt.auxio.music.MusicParent import org.oxycblt.auxio.music.MusicParent
import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.list.Sort
import org.oxycblt.auxio.playback.PlaybackViewModel import org.oxycblt.auxio.playback.PlaybackViewModel
import org.oxycblt.auxio.ui.NavigationViewModel import org.oxycblt.auxio.ui.NavigationViewModel
import org.oxycblt.auxio.util.collect import org.oxycblt.auxio.util.collect

View file

@ -50,11 +50,11 @@ import org.oxycblt.auxio.home.list.ArtistListFragment
import org.oxycblt.auxio.home.list.GenreListFragment import org.oxycblt.auxio.home.list.GenreListFragment
import org.oxycblt.auxio.home.list.SongListFragment import org.oxycblt.auxio.home.list.SongListFragment
import org.oxycblt.auxio.home.tabs.AdaptiveTabStrategy import org.oxycblt.auxio.home.tabs.AdaptiveTabStrategy
import org.oxycblt.auxio.list.Sort
import org.oxycblt.auxio.list.selection.SelectionFragment import org.oxycblt.auxio.list.selection.SelectionFragment
import org.oxycblt.auxio.list.selection.SelectionViewModel import org.oxycblt.auxio.list.selection.SelectionViewModel
import org.oxycblt.auxio.music.* import org.oxycblt.auxio.music.*
import org.oxycblt.auxio.music.library.Library import org.oxycblt.auxio.music.model.Library
import org.oxycblt.auxio.list.Sort
import org.oxycblt.auxio.music.system.Indexer import org.oxycblt.auxio.music.system.Indexer
import org.oxycblt.auxio.playback.PlaybackViewModel import org.oxycblt.auxio.playback.PlaybackViewModel
import org.oxycblt.auxio.ui.MainNavigationAction import org.oxycblt.auxio.ui.MainNavigationAction

View file

@ -23,9 +23,9 @@ import javax.inject.Inject
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import org.oxycblt.auxio.home.tabs.Tab import org.oxycblt.auxio.home.tabs.Tab
import org.oxycblt.auxio.music.*
import org.oxycblt.auxio.music.library.Library
import org.oxycblt.auxio.list.Sort import org.oxycblt.auxio.list.Sort
import org.oxycblt.auxio.music.*
import org.oxycblt.auxio.music.model.Library
import org.oxycblt.auxio.playback.PlaybackSettings import org.oxycblt.auxio.playback.PlaybackSettings
import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.util.logD

View file

@ -31,13 +31,13 @@ import org.oxycblt.auxio.home.HomeViewModel
import org.oxycblt.auxio.home.fastscroll.FastScrollRecyclerView import org.oxycblt.auxio.home.fastscroll.FastScrollRecyclerView
import org.oxycblt.auxio.list.* import org.oxycblt.auxio.list.*
import org.oxycblt.auxio.list.ListFragment import org.oxycblt.auxio.list.ListFragment
import org.oxycblt.auxio.list.Sort
import org.oxycblt.auxio.list.adapter.BasicListInstructions import org.oxycblt.auxio.list.adapter.BasicListInstructions
import org.oxycblt.auxio.list.adapter.ListDiffer import org.oxycblt.auxio.list.adapter.ListDiffer
import org.oxycblt.auxio.list.adapter.SelectionIndicatorAdapter import org.oxycblt.auxio.list.adapter.SelectionIndicatorAdapter
import org.oxycblt.auxio.list.recycler.AlbumViewHolder import org.oxycblt.auxio.list.recycler.AlbumViewHolder
import org.oxycblt.auxio.list.selection.SelectionViewModel import org.oxycblt.auxio.list.selection.SelectionViewModel
import org.oxycblt.auxio.music.* import org.oxycblt.auxio.music.*
import org.oxycblt.auxio.list.Sort
import org.oxycblt.auxio.playback.PlaybackViewModel import org.oxycblt.auxio.playback.PlaybackViewModel
import org.oxycblt.auxio.playback.formatDurationMs import org.oxycblt.auxio.playback.formatDurationMs
import org.oxycblt.auxio.playback.secsToMs import org.oxycblt.auxio.playback.secsToMs

View file

@ -29,6 +29,7 @@ import org.oxycblt.auxio.home.HomeViewModel
import org.oxycblt.auxio.home.fastscroll.FastScrollRecyclerView import org.oxycblt.auxio.home.fastscroll.FastScrollRecyclerView
import org.oxycblt.auxio.list.* import org.oxycblt.auxio.list.*
import org.oxycblt.auxio.list.ListFragment import org.oxycblt.auxio.list.ListFragment
import org.oxycblt.auxio.list.Sort
import org.oxycblt.auxio.list.adapter.BasicListInstructions import org.oxycblt.auxio.list.adapter.BasicListInstructions
import org.oxycblt.auxio.list.adapter.ListDiffer import org.oxycblt.auxio.list.adapter.ListDiffer
import org.oxycblt.auxio.list.adapter.SelectionIndicatorAdapter import org.oxycblt.auxio.list.adapter.SelectionIndicatorAdapter
@ -38,7 +39,6 @@ import org.oxycblt.auxio.music.Artist
import org.oxycblt.auxio.music.Music import org.oxycblt.auxio.music.Music
import org.oxycblt.auxio.music.MusicMode import org.oxycblt.auxio.music.MusicMode
import org.oxycblt.auxio.music.MusicParent import org.oxycblt.auxio.music.MusicParent
import org.oxycblt.auxio.list.Sort
import org.oxycblt.auxio.playback.PlaybackViewModel import org.oxycblt.auxio.playback.PlaybackViewModel
import org.oxycblt.auxio.playback.formatDurationMs import org.oxycblt.auxio.playback.formatDurationMs
import org.oxycblt.auxio.ui.NavigationViewModel import org.oxycblt.auxio.ui.NavigationViewModel

View file

@ -29,6 +29,7 @@ import org.oxycblt.auxio.home.HomeViewModel
import org.oxycblt.auxio.home.fastscroll.FastScrollRecyclerView import org.oxycblt.auxio.home.fastscroll.FastScrollRecyclerView
import org.oxycblt.auxio.list.* import org.oxycblt.auxio.list.*
import org.oxycblt.auxio.list.ListFragment import org.oxycblt.auxio.list.ListFragment
import org.oxycblt.auxio.list.Sort
import org.oxycblt.auxio.list.adapter.BasicListInstructions import org.oxycblt.auxio.list.adapter.BasicListInstructions
import org.oxycblt.auxio.list.adapter.ListDiffer import org.oxycblt.auxio.list.adapter.ListDiffer
import org.oxycblt.auxio.list.adapter.SelectionIndicatorAdapter import org.oxycblt.auxio.list.adapter.SelectionIndicatorAdapter
@ -38,7 +39,6 @@ import org.oxycblt.auxio.music.Genre
import org.oxycblt.auxio.music.Music import org.oxycblt.auxio.music.Music
import org.oxycblt.auxio.music.MusicMode import org.oxycblt.auxio.music.MusicMode
import org.oxycblt.auxio.music.MusicParent import org.oxycblt.auxio.music.MusicParent
import org.oxycblt.auxio.list.Sort
import org.oxycblt.auxio.playback.PlaybackViewModel import org.oxycblt.auxio.playback.PlaybackViewModel
import org.oxycblt.auxio.playback.formatDurationMs import org.oxycblt.auxio.playback.formatDurationMs
import org.oxycblt.auxio.ui.NavigationViewModel import org.oxycblt.auxio.ui.NavigationViewModel

View file

@ -31,6 +31,7 @@ import org.oxycblt.auxio.home.HomeViewModel
import org.oxycblt.auxio.home.fastscroll.FastScrollRecyclerView import org.oxycblt.auxio.home.fastscroll.FastScrollRecyclerView
import org.oxycblt.auxio.list.* import org.oxycblt.auxio.list.*
import org.oxycblt.auxio.list.ListFragment import org.oxycblt.auxio.list.ListFragment
import org.oxycblt.auxio.list.Sort
import org.oxycblt.auxio.list.adapter.BasicListInstructions import org.oxycblt.auxio.list.adapter.BasicListInstructions
import org.oxycblt.auxio.list.adapter.ListDiffer import org.oxycblt.auxio.list.adapter.ListDiffer
import org.oxycblt.auxio.list.adapter.SelectionIndicatorAdapter import org.oxycblt.auxio.list.adapter.SelectionIndicatorAdapter
@ -40,7 +41,6 @@ import org.oxycblt.auxio.music.Music
import org.oxycblt.auxio.music.MusicMode import org.oxycblt.auxio.music.MusicMode
import org.oxycblt.auxio.music.MusicParent import org.oxycblt.auxio.music.MusicParent
import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.list.Sort
import org.oxycblt.auxio.playback.PlaybackViewModel import org.oxycblt.auxio.playback.PlaybackViewModel
import org.oxycblt.auxio.playback.formatDurationMs import org.oxycblt.auxio.playback.formatDurationMs
import org.oxycblt.auxio.playback.secsToMs import org.oxycblt.auxio.playback.secsToMs

View file

@ -30,12 +30,12 @@ import coil.size.Size
import kotlin.math.min import kotlin.math.min
import okio.buffer import okio.buffer
import okio.source import okio.source
import org.oxycblt.auxio.list.Sort
import org.oxycblt.auxio.music.Album import org.oxycblt.auxio.music.Album
import org.oxycblt.auxio.music.Artist import org.oxycblt.auxio.music.Artist
import org.oxycblt.auxio.music.Genre import org.oxycblt.auxio.music.Genre
import org.oxycblt.auxio.music.Music import org.oxycblt.auxio.music.Music
import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.list.Sort
/** /**
* A [Keyer] implementation for [Music] data. * A [Keyer] implementation for [Music] data.

View file

@ -21,8 +21,8 @@ import androidx.annotation.IdRes
import kotlin.math.max import kotlin.math.max
import org.oxycblt.auxio.IntegerTable import org.oxycblt.auxio.IntegerTable
import org.oxycblt.auxio.R import org.oxycblt.auxio.R
import org.oxycblt.auxio.music.*
import org.oxycblt.auxio.list.Sort.Mode import org.oxycblt.auxio.list.Sort.Mode
import org.oxycblt.auxio.music.*
import org.oxycblt.auxio.music.metadata.Date import org.oxycblt.auxio.music.metadata.Date
import org.oxycblt.auxio.music.metadata.Disc import org.oxycblt.auxio.music.metadata.Disc

View file

@ -23,7 +23,7 @@ import javax.inject.Inject
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import org.oxycblt.auxio.music.* import org.oxycblt.auxio.music.*
import org.oxycblt.auxio.music.library.Library import org.oxycblt.auxio.music.model.Library
/** /**
* A [ViewModel] that manages the current selection. * A [ViewModel] that manages the current selection.

View file

@ -17,22 +17,18 @@
package org.oxycblt.auxio.music package org.oxycblt.auxio.music
import android.content.Context import dagger.Binds
import dagger.Module import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton import javax.inject.Singleton
import org.oxycblt.auxio.music.metadata.AudioInfo
import org.oxycblt.auxio.music.system.Indexer import org.oxycblt.auxio.music.system.Indexer
import org.oxycblt.auxio.music.system.IndexerImpl
@Module @Module
@InstallIn(SingletonComponent::class) @InstallIn(SingletonComponent::class)
class MusicModule { interface MusicModule {
@Singleton @Provides fun musicRepository() = MusicRepository.new() @Singleton @Binds fun musicRepository(musicRepository: MusicRepositoryImpl): MusicRepository
@Singleton @Provides fun indexer() = Indexer.new() @Singleton @Binds fun indexer(indexer: IndexerImpl): Indexer
@Provides fun settings(@ApplicationContext context: Context) = MusicSettings.from(context) @Binds fun settings(musicSettingsImpl: MusicSettingsImpl): MusicSettings
@Provides
fun audioInfoProvider(@ApplicationContext context: Context) = AudioInfo.Provider.from(context)
} }

View file

@ -17,7 +17,8 @@
package org.oxycblt.auxio.music package org.oxycblt.auxio.music
import org.oxycblt.auxio.music.library.Library import javax.inject.Inject
import org.oxycblt.auxio.music.model.Library
/** /**
* A repository granting access to the music library. * A repository granting access to the music library.
@ -60,17 +61,9 @@ interface MusicRepository {
*/ */
fun onLibraryChanged(library: Library?) fun onLibraryChanged(library: Library?)
} }
companion object {
/**
* Create a new instance.
* @return A newly-created implementation of [MusicRepository].
*/
fun new(): MusicRepository = RealMusicRepository()
}
} }
private class RealMusicRepository : MusicRepository { class MusicRepositoryImpl @Inject constructor() : MusicRepository {
private val listeners = mutableListOf<MusicRepository.Listener>() private val listeners = mutableListOf<MusicRepository.Listener>()
@Volatile @Volatile

View file

@ -20,6 +20,8 @@ package org.oxycblt.auxio.music
import android.content.Context import android.content.Context
import android.os.storage.StorageManager import android.os.storage.StorageManager
import androidx.core.content.edit import androidx.core.content.edit
import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject
import org.oxycblt.auxio.R import org.oxycblt.auxio.R
import org.oxycblt.auxio.list.Sort import org.oxycblt.auxio.list.Sort
import org.oxycblt.auxio.music.storage.Directory import org.oxycblt.auxio.music.storage.Directory
@ -67,11 +69,11 @@ interface MusicSettings : Settings<MusicSettings.Listener> {
* Get a framework-backed implementation. * Get a framework-backed implementation.
* @param context [Context] required. * @param context [Context] required.
*/ */
fun from(context: Context): MusicSettings = RealMusicSettings(context) fun from(context: Context): MusicSettings = MusicSettingsImpl(context)
} }
} }
private class RealMusicSettings(context: Context) : class MusicSettingsImpl @Inject constructor(@ApplicationContext context: Context) :
Settings.Real<MusicSettings.Listener>(context), MusicSettings { Settings.Real<MusicSettings.Listener>(context), MusicSettings {
private val storageManager = context.getSystemServiceCompat(StorageManager::class) private val storageManager = context.getSystemServiceCompat(StorageManager::class)

View file

@ -1,5 +1,5 @@
/* /*
* Copyright (c) 2022 Auxio Project * Copyright (c) 2023 Auxio Project
* *
* This program is free software: you can redistribute it and/or modify * 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 * it under the terms of the GNU General Public License as published by
@ -15,7 +15,7 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
package org.oxycblt.auxio.music.extractor package org.oxycblt.auxio.music.cache
import android.content.Context import android.content.Context
import androidx.room.Dao import androidx.room.Dao
@ -28,126 +28,23 @@ import androidx.room.Room
import androidx.room.RoomDatabase import androidx.room.RoomDatabase
import androidx.room.TypeConverter import androidx.room.TypeConverter
import androidx.room.TypeConverters import androidx.room.TypeConverters
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.music.library.RawSong
import org.oxycblt.auxio.music.metadata.Date import org.oxycblt.auxio.music.metadata.Date
import org.oxycblt.auxio.music.metadata.correctWhitespace import org.oxycblt.auxio.music.metadata.correctWhitespace
import org.oxycblt.auxio.music.metadata.splitEscaped import org.oxycblt.auxio.music.metadata.splitEscaped
import org.oxycblt.auxio.util.* import org.oxycblt.auxio.music.model.RawSong
/**
* A cache of music metadata obtained in prior music loading operations. Obtain an instance with
* [MetadataCacheRepository].
* @author Alexander Capehart (OxygenCobalt)
*/
interface MetadataCache {
/** Whether this cache has encountered a [RawSong] that did not have a cache entry. */
val invalidated: Boolean
/**
* Populate a [RawSong] from a cache entry, if it exists.
* @param rawSong The [RawSong] to populate.
* @return true if a cache entry could be applied to [rawSong], false otherwise.
*/
fun populate(rawSong: RawSong): Boolean
}
private class RealMetadataCache(cachedSongs: List<CachedSong>) : MetadataCache {
private val cacheMap = buildMap {
for (cachedSong in cachedSongs) {
put(cachedSong.mediaStoreId, cachedSong)
}
}
override var invalidated = false
override fun populate(rawSong: RawSong): 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 = cacheMap[rawSong.mediaStoreId]
if (cachedSong != null &&
cachedSong.dateAdded == rawSong.dateAdded &&
cachedSong.dateModified == rawSong.dateModified) {
cachedSong.copyToRaw(rawSong)
return true
}
// We could not populate this song. This means our cache is stale and should be
// re-written with newly-loaded music.
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 [RawSong]s to the cache, replacing the prior data.
* @param rawSongs The [rawSongs] to write to the cache.
*/
suspend fun writeCache(rawSongs: List<RawSong>)
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<RawSong>) {
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) @Database(entities = [CachedSong::class], version = 27, exportSchema = false)
private abstract class MetadataCacheDatabase : RoomDatabase() { abstract class CacheDatabase : RoomDatabase() {
abstract fun cachedSongsDao(): CachedSongsDao abstract fun cachedSongsDao(): CachedSongsDao
companion object { companion object {
@Volatile private var INSTANCE: MetadataCacheDatabase? = null @Volatile private var INSTANCE: CacheDatabase? = null
/** /**
* Get/create the shared instance of this database. * Get/create the shared instance of this database.
* @param context [Context] required. * @param context [Context] required.
*/ */
fun getInstance(context: Context): MetadataCacheDatabase { fun getInstance(context: Context): CacheDatabase {
val instance = INSTANCE val instance = INSTANCE
if (instance != null) { if (instance != null) {
return instance return instance
@ -157,8 +54,8 @@ private abstract class MetadataCacheDatabase : RoomDatabase() {
val newInstance = val newInstance =
Room.databaseBuilder( Room.databaseBuilder(
context.applicationContext, context.applicationContext,
MetadataCacheDatabase::class.java, CacheDatabase::class.java,
"auxio_metadata_cache.db") "auxio_tag_cache.db")
.fallbackToDestructiveMigration() .fallbackToDestructiveMigration()
.fallbackToDestructiveMigrationFrom(0) .fallbackToDestructiveMigrationFrom(0)
.fallbackToDestructiveMigrationOnDowngrade() .fallbackToDestructiveMigrationOnDowngrade()
@ -171,7 +68,7 @@ private abstract class MetadataCacheDatabase : RoomDatabase() {
} }
@Dao @Dao
private interface CachedSongsDao { interface CachedSongsDao {
@Query("SELECT * FROM ${CachedSong.TABLE_NAME}") suspend fun readSongs(): List<CachedSong> @Query("SELECT * FROM ${CachedSong.TABLE_NAME}") suspend fun readSongs(): List<CachedSong>
@Query("DELETE FROM ${CachedSong.TABLE_NAME}") suspend fun nukeSongs() @Query("DELETE FROM ${CachedSong.TABLE_NAME}") suspend fun nukeSongs()
@Insert suspend fun insertSongs(songs: List<CachedSong>) @Insert suspend fun insertSongs(songs: List<CachedSong>)
@ -179,7 +76,7 @@ private interface CachedSongsDao {
@Entity(tableName = CachedSong.TABLE_NAME) @Entity(tableName = CachedSong.TABLE_NAME)
@TypeConverters(CachedSong.Converters::class) @TypeConverters(CachedSong.Converters::class)
private data class CachedSong( data class CachedSong(
/** /**
* The ID of the [Song]'s audio file, obtained from MediaStore. Note that this ID is highly * The ID of the [Song]'s audio file, obtained from MediaStore. Note that this ID is highly
* unstable and should only be used for accessing the audio file. * unstable and should only be used for accessing the audio file.

View file

@ -15,19 +15,19 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
package org.oxycblt.auxio.playback.persist package org.oxycblt.auxio.music.cache
import androidx.room.TypeConverter import android.content.Context
import org.oxycblt.auxio.music.Music import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import org.oxycblt.auxio.music.extractor.CacheRepository
/** @Module
* Defines conversions used in the persistence table. @InstallIn(SingletonComponent::class)
* @author Alexander Capehart (OxygenCobalt) class CacheModule {
*/ @Provides
object PersistenceConverters { fun cacheRepository(@ApplicationContext context: Context) = CacheRepository.from(context)
/** @see [Music.UID.toString] */
@TypeConverter fun fromMusicUID(uid: Music.UID?) = uid?.toString()
/** @see [Music.UID.fromString] */
@TypeConverter fun toMusicUid(string: String?) = string?.let(Music.UID::fromString)
} }

View file

@ -0,0 +1,126 @@
/*
* 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
import android.content.Context
import org.oxycblt.auxio.music.cache.CacheDatabase
import org.oxycblt.auxio.music.cache.CachedSong
import org.oxycblt.auxio.music.cache.CachedSongsDao
import org.oxycblt.auxio.music.model.RawSong
import org.oxycblt.auxio.util.*
/**
* A cache of music metadata obtained in prior music loading operations. Obtain an instance with
* [CacheRepository].
* @author Alexander Capehart (OxygenCobalt)
*/
interface Cache {
/** Whether this cache has encountered a [RawSong] that did not have a cache entry. */
val invalidated: Boolean
/**
* Populate a [RawSong] from a cache entry, if it exists.
* @param rawSong The [RawSong] to populate.
* @return true if a cache entry could be applied to [rawSong], false otherwise.
*/
fun populate(rawSong: RawSong): Boolean
}
private class RealCache(cachedSongs: List<CachedSong>) : Cache {
private val cacheMap = buildMap {
for (cachedSong in cachedSongs) {
put(cachedSong.mediaStoreId, cachedSong)
}
}
override var invalidated = false
override fun populate(rawSong: RawSong): 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 = cacheMap[rawSong.mediaStoreId]
if (cachedSong != null &&
cachedSong.dateAdded == rawSong.dateAdded &&
cachedSong.dateModified == rawSong.dateModified) {
cachedSong.copyToRaw(rawSong)
return true
}
// We could not populate this song. This means our cache is stale and should be
// re-written with newly-loaded music.
invalidated = true
return false
}
}
/**
* A repository allowing access to cached metadata obtained in prior music loading operations.
* @author Alexander Capehart (OxygenCobalt)
*/
interface CacheRepository {
/**
* Read the current [Cache], if it exists.
* @return The stored [Cache], or null if it could not be obtained.
*/
suspend fun readCache(): Cache?
/**
* Write the list of newly-loaded [RawSong]s to the cache, replacing the prior data.
* @param rawSongs The [rawSongs] to write to the cache.
*/
suspend fun writeCache(rawSongs: List<RawSong>)
companion object {
/**
* Create a framework-backed instance.
* @param context [Context] required.
* @return A new instance.
*/
fun from(context: Context): CacheRepository = RealCacheRepository(context)
}
}
private class RealCacheRepository(private val context: Context) : CacheRepository {
private val cachedSongsDao: CachedSongsDao by lazy {
CacheDatabase.getInstance(context).cachedSongsDao()
}
override suspend fun readCache() =
try {
// Faster to load the whole database into memory than do a query on each
// populate call.
RealCache(cachedSongsDao.readSongs())
} catch (e: Exception) {
logE("Unable to load cache database.")
logE(e.stackTraceToString())
null
}
override suspend fun writeCache(rawSongs: List<RawSong>) {
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())
}
}
}

View file

@ -20,6 +20,8 @@ package org.oxycblt.auxio.music.metadata
import android.content.Context import android.content.Context
import android.media.MediaExtractor import android.media.MediaExtractor
import android.media.MediaFormat import android.media.MediaFormat
import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject
import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.music.storage.MimeType import org.oxycblt.auxio.music.storage.MimeType
import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.util.logD
@ -46,18 +48,15 @@ data class AudioInfo(
* @return The [AudioInfo] of the [Song], if possible to obtain. * @return The [AudioInfo] of the [Song], if possible to obtain.
*/ */
suspend fun extract(song: Song): AudioInfo suspend fun extract(song: Song): AudioInfo
companion object {
/**
* Get a framework-backed implementation.
* @param context [Context] required.
*/
fun from(context: Context): Provider = RealAudioInfoProvider(context)
}
} }
} }
private class RealAudioInfoProvider(private val context: Context) : AudioInfo.Provider { /**
* A framework-backed implementation of [AudioInfo.Provider].
* @param context [Context] required to read audio files.
*/
class AudioInfoProviderImpl @Inject constructor(@ApplicationContext private val context: Context) :
AudioInfo.Provider {
// While we would use ExoPlayer to extract this information, it doesn't support // While we would use ExoPlayer to extract this information, it doesn't support
// common data like bit rate in progressive data sources due to there being no // common data like bit rate in progressive data sources due to there being no
// demand. Thus, we are stuck with the inferior OS-provided MediaExtractor. // demand. Thus, we are stuck with the inferior OS-provided MediaExtractor.

View file

@ -0,0 +1,30 @@
/*
* 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.music.metadata
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
@Module
@InstallIn(SingletonComponent::class)
interface MetadataModule {
@Binds fun tagExtractor(tagExtractor: TagExtractorImpl): TagExtractor
@Binds fun audioInfoProvider(audioInfoProvider: AudioInfoProviderImpl): AudioInfo.Provider
}

View file

@ -1,5 +1,5 @@
/* /*
* Copyright (c) 2022 Auxio Project * Copyright (c) 2023 Auxio Project
* *
* This program is free software: you can redistribute it and/or modify * 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 * it under the terms of the GNU General Public License as published by
@ -15,19 +15,17 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
package org.oxycblt.auxio.music.extractor package org.oxycblt.auxio.music.metadata
import android.content.Context import android.content.Context
import androidx.core.text.isDigitsOnly import androidx.core.text.isDigitsOnly
import com.google.android.exoplayer2.MediaItem import com.google.android.exoplayer2.MediaItem
import com.google.android.exoplayer2.MetadataRetriever import com.google.android.exoplayer2.MetadataRetriever
import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject
import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.yield import kotlinx.coroutines.yield
import org.oxycblt.auxio.music.library.RawSong import org.oxycblt.auxio.music.model.RawSong
import org.oxycblt.auxio.music.metadata.Date
import org.oxycblt.auxio.music.metadata.TextTags
import org.oxycblt.auxio.music.metadata.parseId3v2PositionField
import org.oxycblt.auxio.music.metadata.parseVorbisPositionField
import org.oxycblt.auxio.music.storage.toAudioUri import org.oxycblt.auxio.music.storage.toAudioUri
import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.util.logD
import org.oxycblt.auxio.util.logW import org.oxycblt.auxio.util.logW
@ -35,17 +33,30 @@ import org.oxycblt.auxio.util.logW
/** /**
* The extractor that leverages ExoPlayer's [MetadataRetriever] API to parse metadata. This is the * 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 * last step in the music extraction process and is mostly responsible for papering over the bad
* metadata that [RealMediaStoreExtractor] produces. * metadata that other extractors produce.
* *
* @param context [Context] required for reading audio files.
* @author Alexander Capehart (OxygenCobalt) * @author Alexander Capehart (OxygenCobalt)
*/ */
class MetadataExtractor(private val context: Context) { interface TagExtractor {
// We can parallelize MetadataRetriever Futures to work around it's speed issues, /**
// producing similar throughput's to other kinds of manual metadata extraction. * Extract the metadata of songs from [incompleteSongs] and send them to [completeSongs]. Will
private val taskPool: Array<Task?> = arrayOfNulls(TASK_CAPACITY) * terminate as soon as [incompleteSongs] is closed.
* @param incompleteSongs A [Channel] of incomplete songs to process.
* @param completeSongs A [Channel] to send completed songs to.
*/
suspend fun consume(incompleteSongs: Channel<RawSong>, completeSongs: Channel<RawSong>)
}
class TagExtractorImpl @Inject constructor(@ApplicationContext private val context: Context) :
TagExtractor {
override suspend fun consume(
incompleteSongs: Channel<RawSong>,
completeSongs: Channel<RawSong>
) {
// We can parallelize MetadataRetriever Futures to work around it's speed issues,
// producing similar throughput's to other kinds of manual metadata extraction.
val taskPool: Array<Task?> = arrayOfNulls(TASK_CAPACITY)
suspend fun consume(incompleteSongs: Channel<RawSong>, completeSongs: Channel<RawSong>) {
spin@ while (true) { spin@ while (true) {
// Spin until there is an open slot we can insert a task in. // Spin until there is an open slot we can insert a task in.
for (i in taskPool.indices) { for (i in taskPool.indices) {
@ -94,7 +105,7 @@ class MetadataExtractor(private val context: Context) {
} }
/** /**
* Wraps a [MetadataExtractor] future and processes it into a [RawSong] when completed. * Wraps a [TagExtractor] future and processes it into a [RawSong] when completed.
* @param context [Context] required to open the audio file. * @param context [Context] required to open the audio file.
* @param rawSong [RawSong] to process. * @param rawSong [RawSong] to process.
* @author Alexander Capehart (OxygenCobalt) * @author Alexander Capehart (OxygenCobalt)

View file

@ -15,7 +15,7 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
package org.oxycblt.auxio.music.library package org.oxycblt.auxio.music.model
import android.content.Context import android.content.Context
import android.net.Uri import android.net.Uri

View file

@ -15,7 +15,7 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
package org.oxycblt.auxio.music.library package org.oxycblt.auxio.music.model
import java.util.UUID import java.util.UUID
import org.oxycblt.auxio.music.* import org.oxycblt.auxio.music.*

View file

@ -15,7 +15,7 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
package org.oxycblt.auxio.music.library package org.oxycblt.auxio.music.model
import android.content.Context import android.content.Context
import androidx.annotation.VisibleForTesting import androidx.annotation.VisibleForTesting

View file

@ -15,13 +15,12 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
package org.oxycblt.auxio.music.extractor package org.oxycblt.auxio.music.storage
import android.content.Context import android.content.Context
import android.database.Cursor import android.database.Cursor
import android.os.Build import android.os.Build
import android.os.storage.StorageManager import android.os.storage.StorageManager
import android.os.storage.StorageVolume
import android.provider.MediaStore import android.provider.MediaStore
import androidx.annotation.RequiresApi import androidx.annotation.RequiresApi
import androidx.core.database.getIntOrNull import androidx.core.database.getIntOrNull
@ -30,91 +29,77 @@ import java.io.File
import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.yield import kotlinx.coroutines.yield
import org.oxycblt.auxio.music.MusicSettings import org.oxycblt.auxio.music.MusicSettings
import org.oxycblt.auxio.music.library.RawSong import org.oxycblt.auxio.music.extractor.Cache
import org.oxycblt.auxio.music.metadata.Date import org.oxycblt.auxio.music.metadata.Date
import org.oxycblt.auxio.music.metadata.parseId3v2PositionField import org.oxycblt.auxio.music.metadata.parseId3v2PositionField
import org.oxycblt.auxio.music.metadata.transformPositionField import org.oxycblt.auxio.music.metadata.transformPositionField
import org.oxycblt.auxio.music.storage.Directory import org.oxycblt.auxio.music.model.RawSong
import org.oxycblt.auxio.music.storage.contentResolverSafe
import org.oxycblt.auxio.music.storage.directoryCompat
import org.oxycblt.auxio.music.storage.mediaStoreVolumeNameCompat
import org.oxycblt.auxio.music.storage.safeQuery
import org.oxycblt.auxio.music.storage.storageVolumesCompat
import org.oxycblt.auxio.music.storage.useQuery
import org.oxycblt.auxio.util.getSystemServiceCompat import org.oxycblt.auxio.util.getSystemServiceCompat
import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.util.logD
/** /**
* The layer that loads music from the [MediaStore] database. This is an intermediate step in the * The layer that loads music from the [MediaStore] database. This is an intermediate step in the
* music extraction process and primarily intended for redundancy for files not natively supported * 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 * by other extractors. Solely relying on this is not recommended, as it often produces bad
* metadata. * metadata.
* @author Alexander Capehart (OxygenCobalt) * @author Alexander Capehart (OxygenCobalt)
*/ */
interface MediaStoreExtractor { interface MediaStoreExtractor {
/** /**
* Query the media database, initializing this instance in the process. * Query the media database.
* @return The new [Cursor] returned by the media databases. * @return A new [Query] returned from the media database.
*/ */
suspend fun query(): Cursor suspend fun query(): Query
/** /**
* Consume the [Cursor] loaded after [query]. * Consume the [Cursor] loaded after [query].
* @param cache A [MetadataCache] used to avoid extracting metadata for cached songs, or null if * @param query The [Query] to consume.
* no [MetadataCache] was available. * @param cache A [Cache] used to avoid extracting metadata for cached songs, or null if no
* @param incompleteSongs A channel where songs that could not be retrieved from the * [Cache] was available.
* [MetadataCache] should be sent to. * @param incompleteSongs A channel where songs that could not be retrieved from the [Cache]
* should be sent to.
* @param completeSongs A channel where completed songs should be sent to. * @param completeSongs A channel where completed songs should be sent to.
*/ */
suspend fun consume( suspend fun consume(
cache: MetadataCache?, query: Query,
cache: Cache?,
incompleteSongs: Channel<RawSong>, incompleteSongs: Channel<RawSong>,
completeSongs: Channel<RawSong> completeSongs: Channel<RawSong>
) )
/** A black-box interface representing a query from the media database. */
interface Query {
val projectedTotal: Int
fun moveToNext(): Boolean
fun close()
fun populateFileInfo(rawSong: RawSong)
fun populateTags(rawSong: RawSong)
}
companion object { companion object {
/** /**
* Create a framework-backed instance. * Create a framework-backed instance.
* @param context [Context] required. * @param context [Context] required.
* @param musicSettings [MusicSettings] required.
* @return A new [RealMediaStoreExtractor] that will work best on the device's API level. * @return A new [RealMediaStoreExtractor] that will work best on the device's API level.
*/ */
fun from(context: Context): MediaStoreExtractor = fun from(context: Context, musicSettings: MusicSettings): MediaStoreExtractor =
when { when {
Build.VERSION.SDK_INT >= Build.VERSION_CODES.R -> Api30MediaStoreExtractor(context) Build.VERSION.SDK_INT >= Build.VERSION_CODES.R ->
Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q -> Api29MediaStoreExtractor(context) Api30MediaStoreExtractor(context, musicSettings)
else -> Api21MediaStoreExtractor(context) Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q ->
Api29MediaStoreExtractor(context, musicSettings)
else -> Api21MediaStoreExtractor(context, musicSettings)
} }
} }
} }
private abstract class RealMediaStoreExtractor(private val context: Context) : MediaStoreExtractor { private abstract class RealMediaStoreExtractor(
private var cursor: Cursor? = null protected val context: Context,
private var idIndex = -1 private val musicSettings: MusicSettings
private var titleIndex = -1 ) : MediaStoreExtractor {
private var displayNameIndex = -1 final override suspend fun query(): MediaStoreExtractor.Query {
private var mimeTypeIndex = -1
private var sizeIndex = -1
private var dateAddedIndex = -1
private var dateModifiedIndex = -1
private var durationIndex = -1
private var yearIndex = -1
private var albumIndex = -1
private var albumIdIndex = -1
private var artistIndex = -1
private var albumArtistIndex = -1
private val genreNamesMap = mutableMapOf<Long, String>()
/**
* The [StorageVolume]s currently scanned by [MediaStore]. This should be used to transform path
* information from the database into volume-aware paths.
*/
protected var volumes = listOf<StorageVolume>()
private set
override suspend fun query(): Cursor {
val start = System.currentTimeMillis() val start = System.currentTimeMillis()
val musicSettings = MusicSettings.from(context)
val storageManager = context.getSystemServiceCompat(StorageManager::class)
val args = mutableListOf<String>() val args = mutableListOf<String>()
var selector = BASE_SELECTOR var selector = BASE_SELECTOR
@ -156,30 +141,14 @@ private abstract class RealMediaStoreExtractor(private val context: Context) : M
// Now we can actually query MediaStore. // Now we can actually query MediaStore.
logD("Starting song query [proj: ${projection.toList()}, selector: $selector, args: $args]") logD("Starting song query [proj: ${projection.toList()}, selector: $selector, args: $args]")
val cursor = val cursor =
context.contentResolverSafe context.contentResolverSafe.safeQuery(
.safeQuery( MediaStore.Audio.Media.EXTERNAL_CONTENT_URI,
MediaStore.Audio.Media.EXTERNAL_CONTENT_URI, projection,
projection, selector,
selector, args.toTypedArray())
args.toTypedArray())
.also { cursor = it }
logD("Song query succeeded [Projected total: ${cursor.count}]") logD("Song query succeeded [Projected total: ${cursor.count}]")
// Set up cursor indices for later use. val genreNamesMap = mutableMapOf<Long, String>()
idIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns._ID)
titleIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.TITLE)
displayNameIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.DISPLAY_NAME)
mimeTypeIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.MIME_TYPE)
sizeIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.SIZE)
dateAddedIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.DATE_ADDED)
dateModifiedIndex =
cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.DATE_MODIFIED)
durationIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.DURATION)
yearIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.YEAR)
albumIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.ALBUM)
albumIdIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.ALBUM_ID)
artistIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.ARTIST)
albumArtistIndex = cursor.getColumnIndexOrThrow(AUDIO_COLUMN_ALBUM_ARTIST)
// Since we can't obtain the genre tag from a song query, we must construct our own // Since we can't obtain the genre tag from a song query, we must construct our own
// equivalent from genre database queries. Theoretically, this isn't needed since // equivalent from genre database queries. Theoretically, this isn't needed since
@ -211,34 +180,31 @@ private abstract class RealMediaStoreExtractor(private val context: Context) : M
} }
} }
volumes = storageManager.storageVolumesCompat
logD("Finished initialization in ${System.currentTimeMillis() - start}ms") logD("Finished initialization in ${System.currentTimeMillis() - start}ms")
return wrapQuery(cursor, genreNamesMap)
return cursor
} }
override suspend fun consume( final override suspend fun consume(
cache: MetadataCache?, query: MediaStoreExtractor.Query,
cache: Cache?,
incompleteSongs: Channel<RawSong>, incompleteSongs: Channel<RawSong>,
completeSongs: Channel<RawSong> completeSongs: Channel<RawSong>
) { ) {
val cursor = requireNotNull(cursor) { "Must call query first before running consume" } while (query.moveToNext()) {
while (cursor.moveToNext()) {
val rawSong = RawSong() val rawSong = RawSong()
populateFileData(cursor, rawSong) query.populateFileInfo(rawSong)
if (cache?.populate(rawSong) == true) { if (cache?.populate(rawSong) == true) {
completeSongs.send(rawSong) completeSongs.send(rawSong)
} else { } else {
populateMetadata(cursor, rawSong) query.populateFileInfo(rawSong)
incompleteSongs.send(rawSong) incompleteSongs.send(rawSong)
} }
yield() yield()
} }
// Free the cursor and signal that no more incomplete songs will be produced by // Free the cursor and signal that no more incomplete songs will be produced by
// this extractor. // this extractor.
cursor.close() query.close()
incompleteSongs.close() incompleteSongs.close()
this.cursor = null
} }
/** /**
@ -280,62 +246,80 @@ private abstract class RealMediaStoreExtractor(private val context: Context) : M
*/ */
protected abstract fun addDirToSelector(dir: Directory, args: MutableList<String>): Boolean protected abstract fun addDirToSelector(dir: Directory, args: MutableList<String>): Boolean
/** protected abstract fun wrapQuery(
* Populate a [RawSong] with the "File Data" of the given [MediaStore] [Cursor], which is the cursor: Cursor,
* data that cannot be cached. This includes any information not intrinsic to the file and genreNamesMap: Map<Long, String>
* instead dependent on the file-system, which could change without invalidating the cache due ): MediaStoreExtractor.Query
* to volume additions or removals.
* @param cursor The [Cursor] to read from.
* @param rawSong The [RawSong] to populate.
* @see populateMetadata
*/
protected open fun populateFileData(cursor: Cursor, rawSong: RawSong) {
rawSong.mediaStoreId = cursor.getLong(idIndex)
rawSong.dateAdded = cursor.getLong(dateAddedIndex)
rawSong.dateModified = cursor.getLong(dateAddedIndex)
// Try to use the DISPLAY_NAME column to obtain a (probably sane) file name
// from the android system.
rawSong.fileName = cursor.getStringOrNull(displayNameIndex)
rawSong.extensionMimeType = cursor.getString(mimeTypeIndex)
rawSong.albumMediaStoreId = cursor.getLong(albumIdIndex)
}
/** abstract class Query(
* Populate a [RawSong] with the Metadata of the given [MediaStore] [Cursor], which is the data protected val cursor: Cursor,
* about a [RawSong] that can be cached. This includes any information intrinsic to the file or private val genreNamesMap: Map<Long, String>
* it's file format, such as music tags. ) : MediaStoreExtractor.Query {
* @param cursor The [Cursor] to read from. private val idIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns._ID)
* @param rawSong The [RawSong] to populate. private val titleIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.TITLE)
* @see populateFileData private val displayNameIndex =
*/ cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.DISPLAY_NAME)
protected open fun populateMetadata(cursor: Cursor, rawSong: RawSong) { private val mimeTypeIndex =
// Song title cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.MIME_TYPE)
rawSong.name = cursor.getString(titleIndex) private val sizeIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.SIZE)
// Size (in bytes) private val dateAddedIndex =
rawSong.size = cursor.getLong(sizeIndex) cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.DATE_ADDED)
// Duration (in milliseconds) private val dateModifiedIndex =
rawSong.durationMs = cursor.getLong(durationIndex) cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.DATE_MODIFIED)
// MediaStore only exposes the year value of a file. This is actually worse than it private val durationIndex =
// seems, as it means that it will not read ID3v2 TDRC tags or Vorbis DATE comments. cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.DURATION)
// This is one of the major weaknesses of using MediaStore, hence the redundancy layers. private val yearIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.YEAR)
rawSong.date = cursor.getStringOrNull(yearIndex)?.let(Date::from) private val albumIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.ALBUM)
// A non-existent album name should theoretically be the name of the folder it contained private val albumIdIndex =
// in, but in practice it is more often "0" (as in /storage/emulated/0), even when it the cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.ALBUM_ID)
// file is not actually in the root internal storage directory. We can't do anything to private val artistIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.ARTIST)
// fix this, really. private val albumArtistIndex = cursor.getColumnIndexOrThrow(AUDIO_COLUMN_ALBUM_ARTIST)
rawSong.albumName = cursor.getString(albumIndex)
// Android does not make a non-existent artist tag null, it instead fills it in final override val projectedTotal = cursor.count
// as <unknown>, which makes absolutely no sense given how other columns default final override fun moveToNext() = cursor.moveToNext()
// to null if they are not present. If this column is such, null it so that final override fun close() = cursor.close()
// it's easier to handle later.
val artist = cursor.getString(artistIndex) override fun populateFileInfo(rawSong: RawSong) {
if (artist != MediaStore.UNKNOWN_STRING) { rawSong.mediaStoreId = cursor.getLong(idIndex)
rawSong.artistNames = listOf(artist) rawSong.dateAdded = cursor.getLong(dateAddedIndex)
rawSong.dateModified = cursor.getLong(dateModifiedIndex)
// Try to use the DISPLAY_NAME column to obtain a (probably sane) file name
// from the android system.
rawSong.fileName = cursor.getStringOrNull(displayNameIndex)
rawSong.extensionMimeType = cursor.getString(mimeTypeIndex)
rawSong.albumMediaStoreId = cursor.getLong(albumIdIndex)
}
override fun populateTags(rawSong: RawSong) {
// Song title
rawSong.name = cursor.getString(titleIndex)
// Size (in bytes)
rawSong.size = cursor.getLong(sizeIndex)
// Duration (in milliseconds)
rawSong.durationMs = cursor.getLong(durationIndex)
// MediaStore only exposes the year value of a file. This is actually worse than it
// seems, as it means that it will not read ID3v2 TDRC tags or Vorbis DATE comments.
// This is one of the major weaknesses of using MediaStore, hence the redundancy layers.
rawSong.date = cursor.getStringOrNull(yearIndex)?.let(Date::from)
// A non-existent album name should theoretically be the name of the folder it contained
// in, but in practice it is more often "0" (as in /storage/emulated/0), even when it
// the
// file is not actually in the root internal storage directory. We can't do anything to
// fix this, really.
rawSong.albumName = cursor.getString(albumIndex)
// Android does not make a non-existent artist tag null, it instead fills it in
// as <unknown>, which makes absolutely no sense given how other columns default
// to null if they are not present. If this column is such, null it so that
// it's easier to handle later.
val artist = cursor.getString(artistIndex)
if (artist != MediaStore.UNKNOWN_STRING) {
rawSong.artistNames = listOf(artist)
}
// The album artist column is nullable and never has placeholder values.
cursor.getStringOrNull(albumArtistIndex)?.let { rawSong.albumArtistNames = listOf(it) }
// Get the genre value we had to query for in initialization
genreNamesMap[rawSong.mediaStoreId]?.let { rawSong.genreNames = listOf(it) }
} }
// The album artist column is nullable and never has placeholder values.
cursor.getStringOrNull(albumArtistIndex)?.let { rawSong.albumArtistNames = listOf(it) }
// Get the genre value we had to query for in initialization
genreNamesMap[rawSong.mediaStoreId]?.let { rawSong.genreNames = listOf(it) }
} }
companion object { companion object {
@ -364,18 +348,11 @@ private abstract class RealMediaStoreExtractor(private val context: Context) : M
// Note: The separation between version-specific backends may not be the cleanest. To preserve // 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. // speed, we only want to add redundancy on known issues, not with possible issues.
private class Api21MediaStoreExtractor(context: Context) : RealMediaStoreExtractor(context) { // Note: The separation between version-specific backends may not be the cleanest. To preserve
private var trackIndex = -1 // speed, we only want to add redundancy on known issues, not with possible issues.
private var dataIndex = -1
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)
return cursor
}
private class Api21MediaStoreExtractor(context: Context, musicSettings: MusicSettings) :
RealMediaStoreExtractor(context, musicSettings) {
override val projection: Array<String> override val projection: Array<String>
get() = get() =
super.projection + super.projection +
@ -398,40 +375,56 @@ private class Api21MediaStoreExtractor(context: Context) : RealMediaStoreExtract
return true return true
} }
override fun populateFileData(cursor: Cursor, rawSong: RawSong) { override fun wrapQuery(
super.populateFileData(cursor, rawSong) cursor: Cursor,
genreNamesMap: Map<Long, String>,
): MediaStoreExtractor.Query =
Query(cursor, genreNamesMap, context.getSystemServiceCompat(StorageManager::class))
val data = cursor.getString(dataIndex) private class Query(
cursor: Cursor,
genreNamesMap: Map<Long, String>,
storageManager: StorageManager
) : RealMediaStoreExtractor.Query(cursor, genreNamesMap) {
// Set up cursor indices for later use.
private val trackIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.TRACK)
private val dataIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.DATA)
private val volumes = storageManager.storageVolumesCompat
// On some OEM devices below API 29, DISPLAY_NAME may not be present. I assume override fun populateFileInfo(rawSong: RawSong) {
// that this only applies to below API 29, as beyond API 29, this column not being super.populateFileInfo(rawSong)
// present would completely break the scoped storage system. Fill it in with DATA
// if it's not available.
if (rawSong.fileName == null) {
rawSong.fileName = data.substringAfterLast(File.separatorChar, "").ifEmpty { null }
}
// Find the volume that transforms the DATA column into a relative path. This is val data = cursor.getString(dataIndex)
// the Directory we will use. // On some OEM devices below API 29, DISPLAY_NAME may not be present. I assume
val rawPath = data.substringBeforeLast(File.separatorChar) // that this only applies to below API 29, as beyond API 29, this column not being
for (volume in volumes) { // present would completely break the scoped storage system. Fill it in with DATA
val volumePath = volume.directoryCompat ?: continue // if it's not available.
val strippedPath = rawPath.removePrefix(volumePath) if (rawSong.fileName == null) {
if (strippedPath != rawPath) { rawSong.fileName = data.substringAfterLast(File.separatorChar, "").ifEmpty { null }
rawSong.directory = Directory.from(volume, strippedPath) }
break
// Find the volume that transforms the DATA column into a relative path. This is
// the Directory we will use.
val rawPath = data.substringBeforeLast(File.separatorChar)
for (volume in volumes) {
val volumePath = volume.directoryCompat ?: continue
val strippedPath = rawPath.removePrefix(volumePath)
if (strippedPath != rawPath) {
rawSong.directory = Directory.from(volume, strippedPath)
break
}
} }
} }
}
override fun populateMetadata(cursor: Cursor, rawSong: RawSong) { override fun populateTags(rawSong: RawSong) {
super.populateMetadata(cursor, rawSong) super.populateTags(rawSong)
// See unpackTrackNo/unpackDiscNo for an explanation // See unpackTrackNo/unpackDiscNo for an explanation
// of how this column is set up. // of how this column is set up.
val rawTrack = cursor.getIntOrNull(trackIndex) val rawTrack = cursor.getIntOrNull(trackIndex)
if (rawTrack != null) { if (rawTrack != null) {
rawTrack.unpackTrackNo()?.let { rawSong.track = it } rawTrack.unpackTrackNo()?.let { rawSong.track = it }
rawTrack.unpackDiscNo()?.let { rawSong.disc = it } rawTrack.unpackDiscNo()?.let { rawSong.disc = it }
}
} }
} }
} }
@ -442,20 +435,10 @@ private class Api21MediaStoreExtractor(context: Context) : RealMediaStoreExtract
* @author Alexander Capehart (OxygenCobalt) * @author Alexander Capehart (OxygenCobalt)
*/ */
@RequiresApi(Build.VERSION_CODES.Q) @RequiresApi(Build.VERSION_CODES.Q)
private open class BaseApi29MediaStoreExtractor(context: Context) : private abstract class BaseApi29MediaStoreExtractor(
RealMediaStoreExtractor(context) { context: Context,
private var volumeIndex = -1 musicSettings: MusicSettings
private var relativePathIndex = -1 ) : RealMediaStoreExtractor(context, musicSettings) {
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 =
cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.RELATIVE_PATH)
return cursor
}
override val projection: Array<String> override val projection: Array<String>
get() = get() =
super.projection + super.projection +
@ -484,15 +467,27 @@ private open class BaseApi29MediaStoreExtractor(context: Context) :
return true return true
} }
override fun populateFileData(cursor: Cursor, rawSong: RawSong) { abstract class Query(
super.populateFileData(cursor, rawSong) cursor: Cursor,
// Find the StorageVolume whose MediaStore name corresponds to this song. genreNamesMap: Map<Long, String>,
// This is combined with the plain relative path column to create the directory. storageManager: StorageManager
val volumeName = cursor.getString(volumeIndex) ) : RealMediaStoreExtractor.Query(cursor, genreNamesMap) {
val relativePath = cursor.getString(relativePathIndex) private val volumeIndex =
val volume = volumes.find { it.mediaStoreVolumeNameCompat == volumeName } cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.VOLUME_NAME)
if (volume != null) { private val relativePathIndex =
rawSong.directory = Directory.from(volume, relativePath) cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.RELATIVE_PATH)
private val volumes = storageManager.storageVolumesCompat
final override fun populateFileInfo(rawSong: RawSong) {
super.populateFileInfo(rawSong)
// Find the StorageVolume whose MediaStore name corresponds to this song.
// This is combined with the plain relative path column to create the directory.
val volumeName = cursor.getString(volumeIndex)
val relativePath = cursor.getString(relativePathIndex)
val volume = volumes.find { it.mediaStoreVolumeNameCompat == volumeName }
if (volume != null) {
rawSong.directory = Directory.from(volume, relativePath)
}
} }
} }
} }
@ -505,29 +500,34 @@ private open class BaseApi29MediaStoreExtractor(context: Context) :
* @author Alexander Capehart (OxygenCobalt) * @author Alexander Capehart (OxygenCobalt)
*/ */
@RequiresApi(Build.VERSION_CODES.Q) @RequiresApi(Build.VERSION_CODES.Q)
private open class Api29MediaStoreExtractor(context: Context) : private class Api29MediaStoreExtractor(context: Context, musicSettings: MusicSettings) :
BaseApi29MediaStoreExtractor(context) { BaseApi29MediaStoreExtractor(context, musicSettings) {
private var trackIndex = -1
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
}
override val projection: Array<String> override val projection: Array<String>
get() = super.projection + arrayOf(MediaStore.Audio.AudioColumns.TRACK) get() = super.projection + arrayOf(MediaStore.Audio.AudioColumns.TRACK)
override fun populateMetadata(cursor: Cursor, rawSong: RawSong) { override fun wrapQuery(
super.populateMetadata(cursor, rawSong) cursor: Cursor,
// This extractor is volume-aware, but does not support the modern track columns. genreNamesMap: Map<Long, String>
// Use the old column instead. See unpackTrackNo/unpackDiscNo for an explanation ): MediaStoreExtractor.Query =
// of how this column is set up. Query(cursor, genreNamesMap, context.getSystemServiceCompat(StorageManager::class))
val rawTrack = cursor.getIntOrNull(trackIndex)
if (rawTrack != null) { private class Query(
rawTrack.unpackTrackNo()?.let { rawSong.track = it } cursor: Cursor,
rawTrack.unpackDiscNo()?.let { rawSong.disc = it } genreNamesMap: Map<Long, String>,
storageManager: StorageManager
) : BaseApi29MediaStoreExtractor.Query(cursor, genreNamesMap, storageManager) {
private val trackIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.TRACK)
override fun populateTags(rawSong: RawSong) {
super.populateTags(rawSong)
// This extractor is volume-aware, but does not support the modern track columns.
// Use the old column instead. See unpackTrackNo/unpackDiscNo for an explanation
// of how this column is set up.
val rawTrack = cursor.getIntOrNull(trackIndex)
if (rawTrack != null) {
rawTrack.unpackTrackNo()?.let { rawSong.track = it }
rawTrack.unpackDiscNo()?.let { rawSong.disc = it }
}
} }
} }
} }
@ -539,18 +539,8 @@ private open class Api29MediaStoreExtractor(context: Context) :
* @author Alexander Capehart (OxygenCobalt) * @author Alexander Capehart (OxygenCobalt)
*/ */
@RequiresApi(Build.VERSION_CODES.R) @RequiresApi(Build.VERSION_CODES.R)
private class Api30MediaStoreExtractor(context: Context) : BaseApi29MediaStoreExtractor(context) { private class Api30MediaStoreExtractor(context: Context, musicSettings: MusicSettings) :
private var trackIndex: Int = -1 BaseApi29MediaStoreExtractor(context, musicSettings) {
private var discIndex: Int = -1
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)
return cursor
}
override val projection: Array<String> override val projection: Array<String>
get() = get() =
super.projection + super.projection +
@ -560,14 +550,33 @@ private class Api30MediaStoreExtractor(context: Context) : BaseApi29MediaStoreEx
MediaStore.Audio.AudioColumns.CD_TRACK_NUMBER, MediaStore.Audio.AudioColumns.CD_TRACK_NUMBER,
MediaStore.Audio.AudioColumns.DISC_NUMBER) MediaStore.Audio.AudioColumns.DISC_NUMBER)
override fun populateMetadata(cursor: Cursor, rawSong: RawSong) { override fun wrapQuery(
super.populateMetadata(cursor, rawSong) cursor: Cursor,
// Both CD_TRACK_NUMBER and DISC_NUMBER tend to be formatted as they are in genreNamesMap: Map<Long, String>
// the tag itself, which is to say that it is formatted as NN/TT tracks, where ): MediaStoreExtractor.Query =
// N is the number and T is the total. Parse the number while ignoring the Query(cursor, genreNamesMap, context.getSystemServiceCompat(StorageManager::class))
// total, as we have no use for it.
cursor.getStringOrNull(trackIndex)?.parseId3v2PositionField()?.let { rawSong.track = it } private class Query(
cursor.getStringOrNull(discIndex)?.parseId3v2PositionField()?.let { rawSong.disc = it } cursor: Cursor,
genreNamesMap: Map<Long, String>,
storageManager: StorageManager
) : BaseApi29MediaStoreExtractor.Query(cursor, genreNamesMap, storageManager) {
private val trackIndex =
cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.CD_TRACK_NUMBER)
private val discIndex =
cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.DISC_NUMBER)
override fun populateTags(rawSong: RawSong) {
super.populateTags(rawSong)
// Both CD_TRACK_NUMBER and DISC_NUMBER tend to be formatted as they are in
// the tag itself, which is to say that it is formatted as NN/TT tracks, where
// N is the number and T is the total. Parse the number while ignoring the
// total, as we have no use for it.
cursor.getStringOrNull(trackIndex)?.parseId3v2PositionField()?.let {
rawSong.track = it
}
cursor.getStringOrNull(discIndex)?.parseId3v2PositionField()?.let { rawSong.disc = it }
}
} }
} }

View file

@ -0,0 +1,34 @@
/*
* 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.music.storage
import android.content.Context
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import org.oxycblt.auxio.music.MusicSettings
@Module
@InstallIn(SingletonComponent::class)
class StorageModule {
@Provides
fun mediaStoreExtractor(@ApplicationContext context: Context, musicSettings: MusicSettings) =
MediaStoreExtractor.from(context, musicSettings)
}

View file

@ -23,6 +23,7 @@ import android.content.pm.PackageManager
import android.os.Build import android.os.Build
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import java.util.LinkedList import java.util.LinkedList
import javax.inject.Inject
import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@ -35,8 +36,10 @@ import kotlinx.coroutines.yield
import org.oxycblt.auxio.BuildConfig import org.oxycblt.auxio.BuildConfig
import org.oxycblt.auxio.music.* import org.oxycblt.auxio.music.*
import org.oxycblt.auxio.music.extractor.* import org.oxycblt.auxio.music.extractor.*
import org.oxycblt.auxio.music.library.Library import org.oxycblt.auxio.music.metadata.TagExtractor
import org.oxycblt.auxio.music.library.RawSong import org.oxycblt.auxio.music.model.Library
import org.oxycblt.auxio.music.model.RawSong
import org.oxycblt.auxio.music.storage.MediaStoreExtractor
import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.util.logD
import org.oxycblt.auxio.util.logE import org.oxycblt.auxio.util.logE
import org.oxycblt.auxio.util.logW import org.oxycblt.auxio.util.logW
@ -211,16 +214,17 @@ interface Indexer {
} else { } else {
Manifest.permission.READ_EXTERNAL_STORAGE Manifest.permission.READ_EXTERNAL_STORAGE
} }
/**
* Create a new instance.
* @return A newly-created implementation of [Indexer].
*/
fun new(): Indexer = RealIndexer()
} }
} }
private class RealIndexer : Indexer { class IndexerImpl
@Inject
constructor(
private val musicSettings: MusicSettings,
private val cacheRepository: CacheRepository,
private val mediaStoreExtractor: MediaStoreExtractor,
private val tagExtractor: TagExtractor
) : Indexer {
@Volatile private var lastResponse: Result<Library>? = null @Volatile private var lastResponse: Result<Library>? = null
@Volatile private var indexingState: Indexer.Indexing? = null @Volatile private var indexingState: Indexer.Indexing? = null
@Volatile private var controller: Indexer.Controller? = null @Volatile private var controller: Indexer.Controller? = null
@ -332,19 +336,15 @@ private class RealIndexer : Indexer {
// how long a media database query will take. // how long a media database query will take.
emitIndexing(Indexer.Indexing.Indeterminate) emitIndexing(Indexer.Indexing.Indeterminate)
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. // Do the initial query of the cache and media databases in parallel.
val mediaStoreQueryJob = scope.async { mediaStoreExtractor.query() } val mediaStoreQueryJob = scope.async { mediaStoreExtractor.query() }
val cache = val cache =
if (withCache) { if (withCache) {
metadataCacheRepository.readCache() cacheRepository.readCache()
} else { } else {
null null
} }
val total = mediaStoreQueryJob.await().count val query = mediaStoreQueryJob.await()
// Now start processing the queried song information in parallel. Songs that can't be // 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 // received from the cache are consisted incomplete and pushed to a separate channel
@ -352,14 +352,16 @@ private class RealIndexer : Indexer {
val completeSongs = Channel<RawSong>(Channel.UNLIMITED) val completeSongs = Channel<RawSong>(Channel.UNLIMITED)
val incompleteSongs = Channel<RawSong>(Channel.UNLIMITED) val incompleteSongs = Channel<RawSong>(Channel.UNLIMITED)
val mediaStoreJob = val mediaStoreJob =
scope.async { mediaStoreExtractor.consume(cache, incompleteSongs, completeSongs) } scope.async {
val metadataJob = scope.async { metadataExtractor.consume(incompleteSongs, completeSongs) } mediaStoreExtractor.consume(query, cache, incompleteSongs, completeSongs)
}
val metadataJob = scope.async { tagExtractor.consume(incompleteSongs, completeSongs) }
// Await completed raw songs as they are processed. // Await completed raw songs as they are processed.
val rawSongs = LinkedList<RawSong>() val rawSongs = LinkedList<RawSong>()
for (rawSong in completeSongs) { for (rawSong in completeSongs) {
rawSongs.add(rawSong) rawSongs.add(rawSong)
emitIndexing(Indexer.Indexing.Songs(rawSongs.size, total)) emitIndexing(Indexer.Indexing.Songs(rawSongs.size, query.projectedTotal))
} }
mediaStoreJob.await() mediaStoreJob.await()
metadataJob.await() metadataJob.await()
@ -367,10 +369,9 @@ private class RealIndexer : Indexer {
// Successfully loaded the library, now save the cache and create the library in // Successfully loaded the library, now save the cache and create the library in
// parallel. // parallel.
emitIndexing(Indexer.Indexing.Indeterminate) emitIndexing(Indexer.Indexing.Indeterminate)
val libraryJob = val libraryJob = scope.async(Dispatchers.Main) { Library.from(rawSongs, musicSettings) }
scope.async(Dispatchers.Main) { Library.from(rawSongs, MusicSettings.from(context)) }
if (cache == null || cache.invalidated) { if (cache == null || cache.invalidated) {
metadataCacheRepository.writeCache(rawSongs) cacheRepository.writeCache(rawSongs)
} }
return libraryJob.await() return libraryJob.await()
} }

View file

@ -23,7 +23,7 @@ import javax.inject.Inject
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import org.oxycblt.auxio.music.* import org.oxycblt.auxio.music.*
import org.oxycblt.auxio.music.library.Library import org.oxycblt.auxio.music.model.Library
import org.oxycblt.auxio.util.unlikelyToBeNull import org.oxycblt.auxio.util.unlikelyToBeNull
/** /**

View file

@ -27,6 +27,7 @@ import androidx.room.PrimaryKey
import androidx.room.Query import androidx.room.Query
import androidx.room.Room import androidx.room.Room
import androidx.room.RoomDatabase import androidx.room.RoomDatabase
import androidx.room.TypeConverter
import androidx.room.TypeConverters import androidx.room.TypeConverters
import org.oxycblt.auxio.music.Music import org.oxycblt.auxio.music.Music
import org.oxycblt.auxio.playback.state.RepeatMode import org.oxycblt.auxio.playback.state.RepeatMode
@ -39,7 +40,7 @@ import org.oxycblt.auxio.playback.state.RepeatMode
entities = [PlaybackState::class, QueueHeapItem::class, QueueMappingItem::class], entities = [PlaybackState::class, QueueHeapItem::class, QueueMappingItem::class],
version = 27, version = 27,
exportSchema = false) exportSchema = false)
@TypeConverters(PersistenceConverters::class) @TypeConverters(PersistenceDatabase.Converters::class)
abstract class PersistenceDatabase : RoomDatabase() { abstract class PersistenceDatabase : RoomDatabase() {
/** /**
* Get the current [PlaybackStateDao]. * Get the current [PlaybackStateDao].
@ -53,6 +54,14 @@ abstract class PersistenceDatabase : RoomDatabase() {
*/ */
abstract fun queueDao(): QueueDao abstract fun queueDao(): QueueDao
object Converters {
/** @see [Music.UID.toString] */
@TypeConverter fun fromMusicUID(uid: Music.UID?) = uid?.toString()
/** @see [Music.UID.fromString] */
@TypeConverter fun toMusicUid(string: String?) = string?.let(Music.UID::fromString)
}
companion object { companion object {
@Volatile private var INSTANCE: PersistenceDatabase? = null @Volatile private var INSTANCE: PersistenceDatabase? = null
@ -145,10 +154,6 @@ interface QueueDao {
suspend fun insertMapping(mapping: List<QueueMappingItem>) suspend fun insertMapping(mapping: List<QueueMappingItem>)
} }
/**
* A raw representation of the persisted playback state.
* @author Alexander Capehart
*/
@Entity(tableName = PlaybackState.TABLE_NAME) @Entity(tableName = PlaybackState.TABLE_NAME)
data class PlaybackState( data class PlaybackState(
@PrimaryKey val id: Int, @PrimaryKey val id: Int,
@ -163,10 +168,6 @@ data class PlaybackState(
} }
} }
/**
* A raw representation of the an individual item in the persisted queue's heap.
* @author Alexander Capehart
*/
@Entity(tableName = QueueHeapItem.TABLE_NAME) @Entity(tableName = QueueHeapItem.TABLE_NAME)
data class QueueHeapItem(@PrimaryKey val id: Int, val uid: Music.UID) { data class QueueHeapItem(@PrimaryKey val id: Int, val uid: Music.UID) {
companion object { companion object {
@ -174,10 +175,6 @@ data class QueueHeapItem(@PrimaryKey val id: Int, val uid: Music.UID) {
} }
} }
/**
* A raw representation of the heap indices at a particular position in the persisted queue.
* @author Alexander Capehart
*/
@Entity(tableName = QueueMappingItem.TABLE_NAME) @Entity(tableName = QueueMappingItem.TABLE_NAME)
data class QueueMappingItem( data class QueueMappingItem(
@PrimaryKey val id: Int, @PrimaryKey val id: Int,

View file

@ -19,7 +19,7 @@ package org.oxycblt.auxio.playback.persist
import android.content.Context import android.content.Context
import org.oxycblt.auxio.music.MusicParent import org.oxycblt.auxio.music.MusicParent
import org.oxycblt.auxio.music.library.Library import org.oxycblt.auxio.music.model.Library
import org.oxycblt.auxio.playback.queue.Queue import org.oxycblt.auxio.playback.queue.Queue
import org.oxycblt.auxio.playback.state.PlaybackStateManager import org.oxycblt.auxio.playback.state.PlaybackStateManager
import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.util.logD

View file

@ -48,7 +48,7 @@ import org.oxycblt.auxio.BuildConfig
import org.oxycblt.auxio.music.MusicRepository import org.oxycblt.auxio.music.MusicRepository
import org.oxycblt.auxio.music.MusicSettings import org.oxycblt.auxio.music.MusicSettings
import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.music.library.Library import org.oxycblt.auxio.music.model.Library
import org.oxycblt.auxio.playback.PlaybackSettings import org.oxycblt.auxio.playback.PlaybackSettings
import org.oxycblt.auxio.playback.persist.PersistenceRepository import org.oxycblt.auxio.playback.persist.PersistenceRepository
import org.oxycblt.auxio.playback.replaygain.ReplayGainAudioProcessor import org.oxycblt.auxio.playback.replaygain.ReplayGainAudioProcessor

View file

@ -30,9 +30,9 @@ import kotlinx.coroutines.yield
import org.oxycblt.auxio.R import org.oxycblt.auxio.R
import org.oxycblt.auxio.list.BasicHeader import org.oxycblt.auxio.list.BasicHeader
import org.oxycblt.auxio.list.Item import org.oxycblt.auxio.list.Item
import org.oxycblt.auxio.music.*
import org.oxycblt.auxio.music.library.Library
import org.oxycblt.auxio.list.Sort import org.oxycblt.auxio.list.Sort
import org.oxycblt.auxio.music.*
import org.oxycblt.auxio.music.model.Library
import org.oxycblt.auxio.playback.PlaybackSettings import org.oxycblt.auxio.playback.PlaybackSettings
import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.util.logD

View file

@ -15,7 +15,7 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
package org.oxycblt.auxio.music.library package org.oxycblt.auxio.music.model
import java.util.* import java.util.*
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals