music: make extractors injectable
Refactor the music module to make each individual extractor able to be injected directly.
This commit is contained in:
parent
ae0c68c273
commit
6e55801513
34 changed files with 564 additions and 469 deletions
|
@ -32,6 +32,7 @@ import org.oxycblt.auxio.databinding.FragmentDetailBinding
|
|||
import org.oxycblt.auxio.detail.recycler.AlbumDetailAdapter
|
||||
import org.oxycblt.auxio.list.Item
|
||||
import org.oxycblt.auxio.list.ListFragment
|
||||
import org.oxycblt.auxio.list.Sort
|
||||
import org.oxycblt.auxio.list.adapter.BasicListInstructions
|
||||
import org.oxycblt.auxio.list.selection.SelectionViewModel
|
||||
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.MusicParent
|
||||
import org.oxycblt.auxio.music.Song
|
||||
import org.oxycblt.auxio.list.Sort
|
||||
import org.oxycblt.auxio.playback.PlaybackViewModel
|
||||
import org.oxycblt.auxio.ui.NavigationViewModel
|
||||
import org.oxycblt.auxio.util.*
|
||||
|
|
|
@ -32,6 +32,7 @@ import org.oxycblt.auxio.detail.recycler.ArtistDetailAdapter
|
|||
import org.oxycblt.auxio.detail.recycler.DetailAdapter
|
||||
import org.oxycblt.auxio.list.Item
|
||||
import org.oxycblt.auxio.list.ListFragment
|
||||
import org.oxycblt.auxio.list.Sort
|
||||
import org.oxycblt.auxio.list.adapter.BasicListInstructions
|
||||
import org.oxycblt.auxio.list.selection.SelectionViewModel
|
||||
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.MusicParent
|
||||
import org.oxycblt.auxio.music.Song
|
||||
import org.oxycblt.auxio.list.Sort
|
||||
import org.oxycblt.auxio.playback.PlaybackViewModel
|
||||
import org.oxycblt.auxio.ui.NavigationViewModel
|
||||
import org.oxycblt.auxio.util.collect
|
||||
|
|
|
@ -32,12 +32,12 @@ import org.oxycblt.auxio.R
|
|||
import org.oxycblt.auxio.detail.recycler.SortHeader
|
||||
import org.oxycblt.auxio.list.BasicHeader
|
||||
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.music.*
|
||||
import org.oxycblt.auxio.music.metadata.AudioInfo
|
||||
import org.oxycblt.auxio.music.metadata.Disc
|
||||
import org.oxycblt.auxio.music.metadata.ReleaseType
|
||||
import org.oxycblt.auxio.music.model.Library
|
||||
import org.oxycblt.auxio.playback.PlaybackSettings
|
||||
import org.oxycblt.auxio.util.*
|
||||
|
||||
|
|
|
@ -32,6 +32,7 @@ import org.oxycblt.auxio.detail.recycler.DetailAdapter
|
|||
import org.oxycblt.auxio.detail.recycler.GenreDetailAdapter
|
||||
import org.oxycblt.auxio.list.Item
|
||||
import org.oxycblt.auxio.list.ListFragment
|
||||
import org.oxycblt.auxio.list.Sort
|
||||
import org.oxycblt.auxio.list.adapter.BasicListInstructions
|
||||
import org.oxycblt.auxio.list.selection.SelectionViewModel
|
||||
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.MusicParent
|
||||
import org.oxycblt.auxio.music.Song
|
||||
import org.oxycblt.auxio.list.Sort
|
||||
import org.oxycblt.auxio.playback.PlaybackViewModel
|
||||
import org.oxycblt.auxio.ui.NavigationViewModel
|
||||
import org.oxycblt.auxio.util.collect
|
||||
|
|
|
@ -50,11 +50,11 @@ import org.oxycblt.auxio.home.list.ArtistListFragment
|
|||
import org.oxycblt.auxio.home.list.GenreListFragment
|
||||
import org.oxycblt.auxio.home.list.SongListFragment
|
||||
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.SelectionViewModel
|
||||
import org.oxycblt.auxio.music.*
|
||||
import org.oxycblt.auxio.music.library.Library
|
||||
import org.oxycblt.auxio.list.Sort
|
||||
import org.oxycblt.auxio.music.model.Library
|
||||
import org.oxycblt.auxio.music.system.Indexer
|
||||
import org.oxycblt.auxio.playback.PlaybackViewModel
|
||||
import org.oxycblt.auxio.ui.MainNavigationAction
|
||||
|
|
|
@ -23,9 +23,9 @@ import javax.inject.Inject
|
|||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
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.music.*
|
||||
import org.oxycblt.auxio.music.model.Library
|
||||
import org.oxycblt.auxio.playback.PlaybackSettings
|
||||
import org.oxycblt.auxio.util.logD
|
||||
|
||||
|
|
|
@ -31,13 +31,13 @@ import org.oxycblt.auxio.home.HomeViewModel
|
|||
import org.oxycblt.auxio.home.fastscroll.FastScrollRecyclerView
|
||||
import org.oxycblt.auxio.list.*
|
||||
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.ListDiffer
|
||||
import org.oxycblt.auxio.list.adapter.SelectionIndicatorAdapter
|
||||
import org.oxycblt.auxio.list.recycler.AlbumViewHolder
|
||||
import org.oxycblt.auxio.list.selection.SelectionViewModel
|
||||
import org.oxycblt.auxio.music.*
|
||||
import org.oxycblt.auxio.list.Sort
|
||||
import org.oxycblt.auxio.playback.PlaybackViewModel
|
||||
import org.oxycblt.auxio.playback.formatDurationMs
|
||||
import org.oxycblt.auxio.playback.secsToMs
|
||||
|
|
|
@ -29,6 +29,7 @@ import org.oxycblt.auxio.home.HomeViewModel
|
|||
import org.oxycblt.auxio.home.fastscroll.FastScrollRecyclerView
|
||||
import org.oxycblt.auxio.list.*
|
||||
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.ListDiffer
|
||||
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.MusicMode
|
||||
import org.oxycblt.auxio.music.MusicParent
|
||||
import org.oxycblt.auxio.list.Sort
|
||||
import org.oxycblt.auxio.playback.PlaybackViewModel
|
||||
import org.oxycblt.auxio.playback.formatDurationMs
|
||||
import org.oxycblt.auxio.ui.NavigationViewModel
|
||||
|
|
|
@ -29,6 +29,7 @@ import org.oxycblt.auxio.home.HomeViewModel
|
|||
import org.oxycblt.auxio.home.fastscroll.FastScrollRecyclerView
|
||||
import org.oxycblt.auxio.list.*
|
||||
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.ListDiffer
|
||||
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.MusicMode
|
||||
import org.oxycblt.auxio.music.MusicParent
|
||||
import org.oxycblt.auxio.list.Sort
|
||||
import org.oxycblt.auxio.playback.PlaybackViewModel
|
||||
import org.oxycblt.auxio.playback.formatDurationMs
|
||||
import org.oxycblt.auxio.ui.NavigationViewModel
|
||||
|
|
|
@ -31,6 +31,7 @@ import org.oxycblt.auxio.home.HomeViewModel
|
|||
import org.oxycblt.auxio.home.fastscroll.FastScrollRecyclerView
|
||||
import org.oxycblt.auxio.list.*
|
||||
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.ListDiffer
|
||||
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.MusicParent
|
||||
import org.oxycblt.auxio.music.Song
|
||||
import org.oxycblt.auxio.list.Sort
|
||||
import org.oxycblt.auxio.playback.PlaybackViewModel
|
||||
import org.oxycblt.auxio.playback.formatDurationMs
|
||||
import org.oxycblt.auxio.playback.secsToMs
|
||||
|
|
|
@ -30,12 +30,12 @@ import coil.size.Size
|
|||
import kotlin.math.min
|
||||
import okio.buffer
|
||||
import okio.source
|
||||
import org.oxycblt.auxio.list.Sort
|
||||
import org.oxycblt.auxio.music.Album
|
||||
import org.oxycblt.auxio.music.Artist
|
||||
import org.oxycblt.auxio.music.Genre
|
||||
import org.oxycblt.auxio.music.Music
|
||||
import org.oxycblt.auxio.music.Song
|
||||
import org.oxycblt.auxio.list.Sort
|
||||
|
||||
/**
|
||||
* A [Keyer] implementation for [Music] data.
|
||||
|
|
|
@ -21,8 +21,8 @@ import androidx.annotation.IdRes
|
|||
import kotlin.math.max
|
||||
import org.oxycblt.auxio.IntegerTable
|
||||
import org.oxycblt.auxio.R
|
||||
import org.oxycblt.auxio.music.*
|
||||
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.Disc
|
||||
|
||||
|
|
|
@ -23,7 +23,7 @@ import javax.inject.Inject
|
|||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
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.
|
||||
|
|
|
@ -17,22 +17,18 @@
|
|||
|
||||
package org.oxycblt.auxio.music
|
||||
|
||||
import android.content.Context
|
||||
import dagger.Binds
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import javax.inject.Singleton
|
||||
import org.oxycblt.auxio.music.metadata.AudioInfo
|
||||
import org.oxycblt.auxio.music.system.Indexer
|
||||
import org.oxycblt.auxio.music.system.IndexerImpl
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
class MusicModule {
|
||||
@Singleton @Provides fun musicRepository() = MusicRepository.new()
|
||||
@Singleton @Provides fun indexer() = Indexer.new()
|
||||
@Provides fun settings(@ApplicationContext context: Context) = MusicSettings.from(context)
|
||||
@Provides
|
||||
fun audioInfoProvider(@ApplicationContext context: Context) = AudioInfo.Provider.from(context)
|
||||
interface MusicModule {
|
||||
@Singleton @Binds fun musicRepository(musicRepository: MusicRepositoryImpl): MusicRepository
|
||||
@Singleton @Binds fun indexer(indexer: IndexerImpl): Indexer
|
||||
@Binds fun settings(musicSettingsImpl: MusicSettingsImpl): MusicSettings
|
||||
}
|
||||
|
|
|
@ -17,7 +17,8 @@
|
|||
|
||||
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.
|
||||
|
@ -60,17 +61,9 @@ interface MusicRepository {
|
|||
*/
|
||||
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>()
|
||||
|
||||
@Volatile
|
||||
|
|
|
@ -20,6 +20,8 @@ package org.oxycblt.auxio.music
|
|||
import android.content.Context
|
||||
import android.os.storage.StorageManager
|
||||
import androidx.core.content.edit
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import javax.inject.Inject
|
||||
import org.oxycblt.auxio.R
|
||||
import org.oxycblt.auxio.list.Sort
|
||||
import org.oxycblt.auxio.music.storage.Directory
|
||||
|
@ -67,11 +69,11 @@ interface MusicSettings : Settings<MusicSettings.Listener> {
|
|||
* Get a framework-backed implementation.
|
||||
* @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 {
|
||||
private val storageManager = context.getSystemServiceCompat(StorageManager::class)
|
||||
|
||||
|
|
|
@ -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
|
||||
* 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/>.
|
||||
*/
|
||||
|
||||
package org.oxycblt.auxio.music.extractor
|
||||
package org.oxycblt.auxio.music.cache
|
||||
|
||||
import android.content.Context
|
||||
import androidx.room.Dao
|
||||
|
@ -28,126 +28,23 @@ import androidx.room.Room
|
|||
import androidx.room.RoomDatabase
|
||||
import androidx.room.TypeConverter
|
||||
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.correctWhitespace
|
||||
import org.oxycblt.auxio.music.metadata.splitEscaped
|
||||
import org.oxycblt.auxio.util.*
|
||||
|
||||
/**
|
||||
* 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())
|
||||
}
|
||||
}
|
||||
}
|
||||
import org.oxycblt.auxio.music.model.RawSong
|
||||
|
||||
@Database(entities = [CachedSong::class], version = 27, exportSchema = false)
|
||||
private abstract class MetadataCacheDatabase : RoomDatabase() {
|
||||
abstract class CacheDatabase : RoomDatabase() {
|
||||
abstract fun cachedSongsDao(): CachedSongsDao
|
||||
|
||||
companion object {
|
||||
@Volatile private var INSTANCE: MetadataCacheDatabase? = null
|
||||
@Volatile private var INSTANCE: CacheDatabase? = null
|
||||
|
||||
/**
|
||||
* Get/create the shared instance of this database.
|
||||
* @param context [Context] required.
|
||||
*/
|
||||
fun getInstance(context: Context): MetadataCacheDatabase {
|
||||
fun getInstance(context: Context): CacheDatabase {
|
||||
val instance = INSTANCE
|
||||
if (instance != null) {
|
||||
return instance
|
||||
|
@ -157,8 +54,8 @@ private abstract class MetadataCacheDatabase : RoomDatabase() {
|
|||
val newInstance =
|
||||
Room.databaseBuilder(
|
||||
context.applicationContext,
|
||||
MetadataCacheDatabase::class.java,
|
||||
"auxio_metadata_cache.db")
|
||||
CacheDatabase::class.java,
|
||||
"auxio_tag_cache.db")
|
||||
.fallbackToDestructiveMigration()
|
||||
.fallbackToDestructiveMigrationFrom(0)
|
||||
.fallbackToDestructiveMigrationOnDowngrade()
|
||||
|
@ -171,7 +68,7 @@ private abstract class MetadataCacheDatabase : RoomDatabase() {
|
|||
}
|
||||
|
||||
@Dao
|
||||
private interface CachedSongsDao {
|
||||
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>)
|
||||
|
@ -179,7 +76,7 @@ private interface CachedSongsDao {
|
|||
|
||||
@Entity(tableName = CachedSong.TABLE_NAME)
|
||||
@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
|
||||
* unstable and should only be used for accessing the audio file.
|
|
@ -15,19 +15,19 @@
|
|||
* 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 org.oxycblt.auxio.music.Music
|
||||
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.extractor.CacheRepository
|
||||
|
||||
/**
|
||||
* Defines conversions used in the persistence table.
|
||||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*/
|
||||
object PersistenceConverters {
|
||||
/** @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)
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
class CacheModule {
|
||||
@Provides
|
||||
fun cacheRepository(@ApplicationContext context: Context) = CacheRepository.from(context)
|
||||
}
|
126
app/src/main/java/org/oxycblt/auxio/music/cache/CacheRepository.kt
vendored
Normal file
126
app/src/main/java/org/oxycblt/auxio/music/cache/CacheRepository.kt
vendored
Normal 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())
|
||||
}
|
||||
}
|
||||
}
|
|
@ -20,6 +20,8 @@ package org.oxycblt.auxio.music.metadata
|
|||
import android.content.Context
|
||||
import android.media.MediaExtractor
|
||||
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.storage.MimeType
|
||||
import org.oxycblt.auxio.util.logD
|
||||
|
@ -46,18 +48,15 @@ data class AudioInfo(
|
|||
* @return The [AudioInfo] of the [Song], if possible to obtain.
|
||||
*/
|
||||
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
|
||||
// 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.
|
||||
|
|
|
@ -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
|
||||
}
|
|
@ -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
|
||||
* 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/>.
|
||||
*/
|
||||
|
||||
package org.oxycblt.auxio.music.extractor
|
||||
package org.oxycblt.auxio.music.metadata
|
||||
|
||||
import android.content.Context
|
||||
import androidx.core.text.isDigitsOnly
|
||||
import com.google.android.exoplayer2.MediaItem
|
||||
import com.google.android.exoplayer2.MetadataRetriever
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import javax.inject.Inject
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.yield
|
||||
import org.oxycblt.auxio.music.library.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.model.RawSong
|
||||
import org.oxycblt.auxio.music.storage.toAudioUri
|
||||
import org.oxycblt.auxio.util.logD
|
||||
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
|
||||
* 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)
|
||||
*/
|
||||
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)
|
||||
interface TagExtractor {
|
||||
/**
|
||||
* Extract the metadata of songs from [incompleteSongs] and send them to [completeSongs]. Will
|
||||
* 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 until there is an open slot we can insert a task in.
|
||||
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 rawSong [RawSong] to process.
|
||||
* @author Alexander Capehart (OxygenCobalt)
|
|
@ -15,7 +15,7 @@
|
|||
* 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.net.Uri
|
|
@ -15,7 +15,7 @@
|
|||
* 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 org.oxycblt.auxio.music.*
|
|
@ -15,7 +15,7 @@
|
|||
* 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 androidx.annotation.VisibleForTesting
|
|
@ -15,13 +15,12 @@
|
|||
* 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.database.Cursor
|
||||
import android.os.Build
|
||||
import android.os.storage.StorageManager
|
||||
import android.os.storage.StorageVolume
|
||||
import android.provider.MediaStore
|
||||
import androidx.annotation.RequiresApi
|
||||
import androidx.core.database.getIntOrNull
|
||||
|
@ -30,91 +29,77 @@ import java.io.File
|
|||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.yield
|
||||
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.parseId3v2PositionField
|
||||
import org.oxycblt.auxio.music.metadata.transformPositionField
|
||||
import org.oxycblt.auxio.music.storage.Directory
|
||||
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.music.model.RawSong
|
||||
import org.oxycblt.auxio.util.getSystemServiceCompat
|
||||
import org.oxycblt.auxio.util.logD
|
||||
|
||||
/**
|
||||
* 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
|
||||
* 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.
|
||||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*/
|
||||
interface MediaStoreExtractor {
|
||||
/**
|
||||
* Query the media database, initializing this instance in the process.
|
||||
* @return The new [Cursor] returned by the media databases.
|
||||
* Query the media database.
|
||||
* @return A new [Query] returned from the media database.
|
||||
*/
|
||||
suspend fun query(): Cursor
|
||||
suspend fun query(): Query
|
||||
|
||||
/**
|
||||
* 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 query The [Query] to consume.
|
||||
* @param cache A [Cache] used to avoid extracting metadata for cached songs, or null if no
|
||||
* [Cache] was available.
|
||||
* @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.
|
||||
*/
|
||||
suspend fun consume(
|
||||
cache: MetadataCache?,
|
||||
query: Query,
|
||||
cache: Cache?,
|
||||
incompleteSongs: 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 {
|
||||
/**
|
||||
* Create a framework-backed instance.
|
||||
* @param context [Context] required.
|
||||
* @param musicSettings [MusicSettings] required.
|
||||
* @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 {
|
||||
Build.VERSION.SDK_INT >= Build.VERSION_CODES.R -> Api30MediaStoreExtractor(context)
|
||||
Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q -> Api29MediaStoreExtractor(context)
|
||||
else -> Api21MediaStoreExtractor(context)
|
||||
Build.VERSION.SDK_INT >= Build.VERSION_CODES.R ->
|
||||
Api30MediaStoreExtractor(context, musicSettings)
|
||||
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 var cursor: Cursor? = null
|
||||
private var idIndex = -1
|
||||
private var titleIndex = -1
|
||||
private var displayNameIndex = -1
|
||||
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 {
|
||||
private abstract class RealMediaStoreExtractor(
|
||||
protected val context: Context,
|
||||
private val musicSettings: MusicSettings
|
||||
) : MediaStoreExtractor {
|
||||
final override suspend fun query(): MediaStoreExtractor.Query {
|
||||
val start = System.currentTimeMillis()
|
||||
val musicSettings = MusicSettings.from(context)
|
||||
val storageManager = context.getSystemServiceCompat(StorageManager::class)
|
||||
|
||||
val args = mutableListOf<String>()
|
||||
var selector = BASE_SELECTOR
|
||||
|
@ -156,30 +141,14 @@ private abstract class RealMediaStoreExtractor(private val context: Context) : M
|
|||
// Now we can actually query MediaStore.
|
||||
logD("Starting song query [proj: ${projection.toList()}, selector: $selector, args: $args]")
|
||||
val cursor =
|
||||
context.contentResolverSafe
|
||||
.safeQuery(
|
||||
MediaStore.Audio.Media.EXTERNAL_CONTENT_URI,
|
||||
projection,
|
||||
selector,
|
||||
args.toTypedArray())
|
||||
.also { cursor = it }
|
||||
context.contentResolverSafe.safeQuery(
|
||||
MediaStore.Audio.Media.EXTERNAL_CONTENT_URI,
|
||||
projection,
|
||||
selector,
|
||||
args.toTypedArray())
|
||||
logD("Song query succeeded [Projected total: ${cursor.count}]")
|
||||
|
||||
// Set up cursor indices for later use.
|
||||
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)
|
||||
val genreNamesMap = mutableMapOf<Long, String>()
|
||||
|
||||
// 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
|
||||
|
@ -211,34 +180,31 @@ private abstract class RealMediaStoreExtractor(private val context: Context) : M
|
|||
}
|
||||
}
|
||||
|
||||
volumes = storageManager.storageVolumesCompat
|
||||
logD("Finished initialization in ${System.currentTimeMillis() - start}ms")
|
||||
|
||||
return cursor
|
||||
return wrapQuery(cursor, genreNamesMap)
|
||||
}
|
||||
|
||||
override suspend fun consume(
|
||||
cache: MetadataCache?,
|
||||
final override suspend fun consume(
|
||||
query: MediaStoreExtractor.Query,
|
||||
cache: Cache?,
|
||||
incompleteSongs: Channel<RawSong>,
|
||||
completeSongs: Channel<RawSong>
|
||||
) {
|
||||
val cursor = requireNotNull(cursor) { "Must call query first before running consume" }
|
||||
while (cursor.moveToNext()) {
|
||||
while (query.moveToNext()) {
|
||||
val rawSong = RawSong()
|
||||
populateFileData(cursor, rawSong)
|
||||
query.populateFileInfo(rawSong)
|
||||
if (cache?.populate(rawSong) == true) {
|
||||
completeSongs.send(rawSong)
|
||||
} else {
|
||||
populateMetadata(cursor, rawSong)
|
||||
query.populateFileInfo(rawSong)
|
||||
incompleteSongs.send(rawSong)
|
||||
}
|
||||
yield()
|
||||
}
|
||||
// Free the cursor and signal that no more incomplete songs will be produced by
|
||||
// this extractor.
|
||||
cursor.close()
|
||||
query.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
|
||||
|
||||
/**
|
||||
* Populate a [RawSong] with the "File Data" of the given [MediaStore] [Cursor], which is the
|
||||
* data that cannot be cached. This includes any information not intrinsic to the file and
|
||||
* instead dependent on the file-system, which could change without invalidating the cache due
|
||||
* 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)
|
||||
}
|
||||
protected abstract fun wrapQuery(
|
||||
cursor: Cursor,
|
||||
genreNamesMap: Map<Long, String>
|
||||
): MediaStoreExtractor.Query
|
||||
|
||||
/**
|
||||
* Populate a [RawSong] with the Metadata of the given [MediaStore] [Cursor], which is the data
|
||||
* about a [RawSong] that can be cached. This includes any information intrinsic to the file or
|
||||
* it's file format, such as music tags.
|
||||
* @param cursor The [Cursor] to read from.
|
||||
* @param rawSong The [RawSong] to populate.
|
||||
* @see populateFileData
|
||||
*/
|
||||
protected open fun populateMetadata(cursor: Cursor, 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)
|
||||
abstract class Query(
|
||||
protected val cursor: Cursor,
|
||||
private val genreNamesMap: Map<Long, String>
|
||||
) : MediaStoreExtractor.Query {
|
||||
private val idIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns._ID)
|
||||
private val titleIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.TITLE)
|
||||
private val displayNameIndex =
|
||||
cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.DISPLAY_NAME)
|
||||
private val mimeTypeIndex =
|
||||
cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.MIME_TYPE)
|
||||
private val sizeIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.SIZE)
|
||||
private val dateAddedIndex =
|
||||
cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.DATE_ADDED)
|
||||
private val dateModifiedIndex =
|
||||
cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.DATE_MODIFIED)
|
||||
private val durationIndex =
|
||||
cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.DURATION)
|
||||
private val yearIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.YEAR)
|
||||
private val albumIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.ALBUM)
|
||||
private val albumIdIndex =
|
||||
cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.ALBUM_ID)
|
||||
private val artistIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.ARTIST)
|
||||
private val albumArtistIndex = cursor.getColumnIndexOrThrow(AUDIO_COLUMN_ALBUM_ARTIST)
|
||||
|
||||
final override val projectedTotal = cursor.count
|
||||
final override fun moveToNext() = cursor.moveToNext()
|
||||
final override fun close() = cursor.close()
|
||||
|
||||
override fun populateFileInfo(rawSong: RawSong) {
|
||||
rawSong.mediaStoreId = cursor.getLong(idIndex)
|
||||
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 {
|
||||
|
@ -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
|
||||
// speed, we only want to add redundancy on known issues, not with possible issues.
|
||||
|
||||
private class Api21MediaStoreExtractor(context: Context) : RealMediaStoreExtractor(context) {
|
||||
private var trackIndex = -1
|
||||
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
|
||||
}
|
||||
// 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.
|
||||
|
||||
private class Api21MediaStoreExtractor(context: Context, musicSettings: MusicSettings) :
|
||||
RealMediaStoreExtractor(context, musicSettings) {
|
||||
override val projection: Array<String>
|
||||
get() =
|
||||
super.projection +
|
||||
|
@ -398,40 +375,56 @@ private class Api21MediaStoreExtractor(context: Context) : RealMediaStoreExtract
|
|||
return true
|
||||
}
|
||||
|
||||
override fun populateFileData(cursor: Cursor, rawSong: RawSong) {
|
||||
super.populateFileData(cursor, rawSong)
|
||||
override fun wrapQuery(
|
||||
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
|
||||
// that this only applies to below API 29, as beyond API 29, this column not being
|
||||
// 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 }
|
||||
}
|
||||
override fun populateFileInfo(rawSong: RawSong) {
|
||||
super.populateFileInfo(rawSong)
|
||||
|
||||
// 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
|
||||
val data = cursor.getString(dataIndex)
|
||||
// On some OEM devices below API 29, DISPLAY_NAME may not be present. I assume
|
||||
// that this only applies to below API 29, as beyond API 29, this column not being
|
||||
// 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
|
||||
// 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) {
|
||||
super.populateMetadata(cursor, rawSong)
|
||||
// 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 }
|
||||
override fun populateTags(rawSong: RawSong) {
|
||||
super.populateTags(rawSong)
|
||||
// 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 }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -442,20 +435,10 @@ private class Api21MediaStoreExtractor(context: Context) : RealMediaStoreExtract
|
|||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*/
|
||||
@RequiresApi(Build.VERSION_CODES.Q)
|
||||
private open class BaseApi29MediaStoreExtractor(context: Context) :
|
||||
RealMediaStoreExtractor(context) {
|
||||
private var volumeIndex = -1
|
||||
private var relativePathIndex = -1
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
private abstract class BaseApi29MediaStoreExtractor(
|
||||
context: Context,
|
||||
musicSettings: MusicSettings
|
||||
) : RealMediaStoreExtractor(context, musicSettings) {
|
||||
override val projection: Array<String>
|
||||
get() =
|
||||
super.projection +
|
||||
|
@ -484,15 +467,27 @@ private open class BaseApi29MediaStoreExtractor(context: Context) :
|
|||
return true
|
||||
}
|
||||
|
||||
override fun populateFileData(cursor: Cursor, rawSong: RawSong) {
|
||||
super.populateFileData(cursor, 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)
|
||||
abstract class Query(
|
||||
cursor: Cursor,
|
||||
genreNamesMap: Map<Long, String>,
|
||||
storageManager: StorageManager
|
||||
) : RealMediaStoreExtractor.Query(cursor, genreNamesMap) {
|
||||
private val volumeIndex =
|
||||
cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.VOLUME_NAME)
|
||||
private val relativePathIndex =
|
||||
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)
|
||||
*/
|
||||
@RequiresApi(Build.VERSION_CODES.Q)
|
||||
private open class Api29MediaStoreExtractor(context: Context) :
|
||||
BaseApi29MediaStoreExtractor(context) {
|
||||
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
|
||||
}
|
||||
private class Api29MediaStoreExtractor(context: Context, musicSettings: MusicSettings) :
|
||||
BaseApi29MediaStoreExtractor(context, musicSettings) {
|
||||
|
||||
override val projection: Array<String>
|
||||
get() = super.projection + arrayOf(MediaStore.Audio.AudioColumns.TRACK)
|
||||
|
||||
override fun populateMetadata(cursor: Cursor, rawSong: RawSong) {
|
||||
super.populateMetadata(cursor, 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 }
|
||||
override fun wrapQuery(
|
||||
cursor: Cursor,
|
||||
genreNamesMap: Map<Long, String>
|
||||
): MediaStoreExtractor.Query =
|
||||
Query(cursor, genreNamesMap, context.getSystemServiceCompat(StorageManager::class))
|
||||
|
||||
private class Query(
|
||||
cursor: Cursor,
|
||||
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)
|
||||
*/
|
||||
@RequiresApi(Build.VERSION_CODES.R)
|
||||
private class Api30MediaStoreExtractor(context: Context) : BaseApi29MediaStoreExtractor(context) {
|
||||
private var trackIndex: Int = -1
|
||||
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
|
||||
}
|
||||
|
||||
private class Api30MediaStoreExtractor(context: Context, musicSettings: MusicSettings) :
|
||||
BaseApi29MediaStoreExtractor(context, musicSettings) {
|
||||
override val projection: Array<String>
|
||||
get() =
|
||||
super.projection +
|
||||
|
@ -560,14 +550,33 @@ private class Api30MediaStoreExtractor(context: Context) : BaseApi29MediaStoreEx
|
|||
MediaStore.Audio.AudioColumns.CD_TRACK_NUMBER,
|
||||
MediaStore.Audio.AudioColumns.DISC_NUMBER)
|
||||
|
||||
override fun populateMetadata(cursor: Cursor, rawSong: RawSong) {
|
||||
super.populateMetadata(cursor, 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 }
|
||||
override fun wrapQuery(
|
||||
cursor: Cursor,
|
||||
genreNamesMap: Map<Long, String>
|
||||
): MediaStoreExtractor.Query =
|
||||
Query(cursor, genreNamesMap, context.getSystemServiceCompat(StorageManager::class))
|
||||
|
||||
private class Query(
|
||||
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 }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
|
@ -23,6 +23,7 @@ import android.content.pm.PackageManager
|
|||
import android.os.Build
|
||||
import androidx.core.content.ContextCompat
|
||||
import java.util.LinkedList
|
||||
import javax.inject.Inject
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
|
@ -35,8 +36,10 @@ import kotlinx.coroutines.yield
|
|||
import org.oxycblt.auxio.BuildConfig
|
||||
import org.oxycblt.auxio.music.*
|
||||
import org.oxycblt.auxio.music.extractor.*
|
||||
import org.oxycblt.auxio.music.library.Library
|
||||
import org.oxycblt.auxio.music.library.RawSong
|
||||
import org.oxycblt.auxio.music.metadata.TagExtractor
|
||||
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.logE
|
||||
import org.oxycblt.auxio.util.logW
|
||||
|
@ -211,16 +214,17 @@ interface Indexer {
|
|||
} else {
|
||||
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 indexingState: Indexer.Indexing? = null
|
||||
@Volatile private var controller: Indexer.Controller? = null
|
||||
|
@ -332,19 +336,15 @@ private class RealIndexer : Indexer {
|
|||
// how long a media database query will take.
|
||||
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.
|
||||
val mediaStoreQueryJob = scope.async { mediaStoreExtractor.query() }
|
||||
val cache =
|
||||
if (withCache) {
|
||||
metadataCacheRepository.readCache()
|
||||
cacheRepository.readCache()
|
||||
} else {
|
||||
null
|
||||
}
|
||||
val total = mediaStoreQueryJob.await().count
|
||||
val query = mediaStoreQueryJob.await()
|
||||
|
||||
// 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
|
||||
|
@ -352,14 +352,16 @@ private class RealIndexer : Indexer {
|
|||
val completeSongs = Channel<RawSong>(Channel.UNLIMITED)
|
||||
val incompleteSongs = Channel<RawSong>(Channel.UNLIMITED)
|
||||
val mediaStoreJob =
|
||||
scope.async { mediaStoreExtractor.consume(cache, incompleteSongs, completeSongs) }
|
||||
val metadataJob = scope.async { metadataExtractor.consume(incompleteSongs, completeSongs) }
|
||||
scope.async {
|
||||
mediaStoreExtractor.consume(query, cache, incompleteSongs, completeSongs)
|
||||
}
|
||||
val metadataJob = scope.async { tagExtractor.consume(incompleteSongs, completeSongs) }
|
||||
|
||||
// Await completed raw songs as they are processed.
|
||||
val rawSongs = LinkedList<RawSong>()
|
||||
for (rawSong in completeSongs) {
|
||||
rawSongs.add(rawSong)
|
||||
emitIndexing(Indexer.Indexing.Songs(rawSongs.size, total))
|
||||
emitIndexing(Indexer.Indexing.Songs(rawSongs.size, query.projectedTotal))
|
||||
}
|
||||
mediaStoreJob.await()
|
||||
metadataJob.await()
|
||||
|
@ -367,10 +369,9 @@ private class RealIndexer : Indexer {
|
|||
// Successfully loaded the library, now save the cache and create the library in
|
||||
// parallel.
|
||||
emitIndexing(Indexer.Indexing.Indeterminate)
|
||||
val libraryJob =
|
||||
scope.async(Dispatchers.Main) { Library.from(rawSongs, MusicSettings.from(context)) }
|
||||
val libraryJob = scope.async(Dispatchers.Main) { Library.from(rawSongs, musicSettings) }
|
||||
if (cache == null || cache.invalidated) {
|
||||
metadataCacheRepository.writeCache(rawSongs)
|
||||
cacheRepository.writeCache(rawSongs)
|
||||
}
|
||||
return libraryJob.await()
|
||||
}
|
||||
|
|
|
@ -23,7 +23,7 @@ import javax.inject.Inject
|
|||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
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
|
||||
|
||||
/**
|
||||
|
|
|
@ -27,6 +27,7 @@ import androidx.room.PrimaryKey
|
|||
import androidx.room.Query
|
||||
import androidx.room.Room
|
||||
import androidx.room.RoomDatabase
|
||||
import androidx.room.TypeConverter
|
||||
import androidx.room.TypeConverters
|
||||
import org.oxycblt.auxio.music.Music
|
||||
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],
|
||||
version = 27,
|
||||
exportSchema = false)
|
||||
@TypeConverters(PersistenceConverters::class)
|
||||
@TypeConverters(PersistenceDatabase.Converters::class)
|
||||
abstract class PersistenceDatabase : RoomDatabase() {
|
||||
/**
|
||||
* Get the current [PlaybackStateDao].
|
||||
|
@ -53,6 +54,14 @@ abstract class PersistenceDatabase : RoomDatabase() {
|
|||
*/
|
||||
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 {
|
||||
@Volatile private var INSTANCE: PersistenceDatabase? = null
|
||||
|
||||
|
@ -145,10 +154,6 @@ interface QueueDao {
|
|||
suspend fun insertMapping(mapping: List<QueueMappingItem>)
|
||||
}
|
||||
|
||||
/**
|
||||
* A raw representation of the persisted playback state.
|
||||
* @author Alexander Capehart
|
||||
*/
|
||||
@Entity(tableName = PlaybackState.TABLE_NAME)
|
||||
data class PlaybackState(
|
||||
@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)
|
||||
data class QueueHeapItem(@PrimaryKey val id: Int, val uid: Music.UID) {
|
||||
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)
|
||||
data class QueueMappingItem(
|
||||
@PrimaryKey val id: Int,
|
||||
|
|
|
@ -19,7 +19,7 @@ package org.oxycblt.auxio.playback.persist
|
|||
|
||||
import android.content.Context
|
||||
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.state.PlaybackStateManager
|
||||
import org.oxycblt.auxio.util.logD
|
||||
|
|
|
@ -48,7 +48,7 @@ import org.oxycblt.auxio.BuildConfig
|
|||
import org.oxycblt.auxio.music.MusicRepository
|
||||
import org.oxycblt.auxio.music.MusicSettings
|
||||
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.persist.PersistenceRepository
|
||||
import org.oxycblt.auxio.playback.replaygain.ReplayGainAudioProcessor
|
||||
|
|
|
@ -30,9 +30,9 @@ import kotlinx.coroutines.yield
|
|||
import org.oxycblt.auxio.R
|
||||
import org.oxycblt.auxio.list.BasicHeader
|
||||
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.music.*
|
||||
import org.oxycblt.auxio.music.model.Library
|
||||
import org.oxycblt.auxio.playback.PlaybackSettings
|
||||
import org.oxycblt.auxio.util.logD
|
||||
|
||||
|
|
|
@ -15,7 +15,7 @@
|
|||
* 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 org.junit.Assert.assertEquals
|
Loading…
Reference in a new issue