music: switch cache to room

Make the cache extractors rely on a Room database as well.

I'm not quite sure how, but this also actually resulted in a huge speed
improvement.
This commit is contained in:
Alexander Capehart 2023-01-23 15:10:30 -07:00
parent f27215a4be
commit d2f74fd138
No known key found for this signature in database
GPG key ID: 37DBE3621FE9AD47
23 changed files with 262 additions and 474 deletions

View file

@ -33,11 +33,11 @@ import org.oxycblt.auxio.list.BasicHeader
import org.oxycblt.auxio.list.Item
import org.oxycblt.auxio.music.*
import org.oxycblt.auxio.music.MusicStore
import org.oxycblt.auxio.music.extractor.AudioInfo
import org.oxycblt.auxio.music.format.AudioInfo
import org.oxycblt.auxio.music.format.Disc
import org.oxycblt.auxio.music.format.ReleaseType
import org.oxycblt.auxio.music.library.Library
import org.oxycblt.auxio.music.library.Sort
import org.oxycblt.auxio.music.tags.Disc
import org.oxycblt.auxio.music.tags.ReleaseType
import org.oxycblt.auxio.playback.PlaybackSettings
import org.oxycblt.auxio.util.*

View file

@ -27,7 +27,7 @@ import androidx.navigation.fragment.navArgs
import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.DialogSongDetailBinding
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.music.extractor.AudioInfo
import org.oxycblt.auxio.music.format.AudioInfo
import org.oxycblt.auxio.playback.formatDurationMs
import org.oxycblt.auxio.ui.ViewBindingDialogFragment
import org.oxycblt.auxio.util.androidActivityViewModels

View file

@ -33,7 +33,7 @@ import org.oxycblt.auxio.list.adapter.SelectionIndicatorAdapter
import org.oxycblt.auxio.list.adapter.SimpleDiffCallback
import org.oxycblt.auxio.music.Album
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.music.tags.Disc
import org.oxycblt.auxio.music.format.Disc
import org.oxycblt.auxio.playback.formatDurationMs
import org.oxycblt.auxio.util.context
import org.oxycblt.auxio.util.getPlural

View file

@ -31,13 +31,13 @@ import kotlinx.parcelize.IgnoredOnParcel
import kotlinx.parcelize.Parcelize
import org.oxycblt.auxio.R
import org.oxycblt.auxio.list.Item
import org.oxycblt.auxio.music.format.Date
import org.oxycblt.auxio.music.format.Disc
import org.oxycblt.auxio.music.format.ReleaseType
import org.oxycblt.auxio.music.library.Sort
import org.oxycblt.auxio.music.parsing.parseId3GenreNames
import org.oxycblt.auxio.music.parsing.parseMultiValue
import org.oxycblt.auxio.music.storage.*
import org.oxycblt.auxio.music.tags.Date
import org.oxycblt.auxio.music.tags.Disc
import org.oxycblt.auxio.music.tags.ReleaseType
import org.oxycblt.auxio.util.nonZeroOrNull
import org.oxycblt.auxio.util.unlikelyToBeNull

View file

@ -17,16 +17,21 @@
package org.oxycblt.auxio.music.extractor
import android.content.ContentValues
import android.content.Context
import android.database.sqlite.SQLiteDatabase
import android.database.sqlite.SQLiteOpenHelper
import androidx.core.database.getIntOrNull
import androidx.core.database.getStringOrNull
import androidx.room.Dao
import androidx.room.Database
import androidx.room.Entity
import androidx.room.Insert
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.Song
import org.oxycblt.auxio.music.format.Date
import org.oxycblt.auxio.music.parsing.correctWhitespace
import org.oxycblt.auxio.music.parsing.splitEscaped
import org.oxycblt.auxio.music.tags.Date
import org.oxycblt.auxio.util.*
/**
@ -37,14 +42,14 @@ import org.oxycblt.auxio.util.*
*/
interface CacheExtractor {
/** Initialize the Extractor by reading the cache data into memory. */
fun init()
suspend fun init()
/**
* Finalize the Extractor by writing the newly-loaded [Song.Raw]s back into the cache, alongside
* freeing up memory.
* @param rawSongs The songs to write into the cache.
*/
fun finalize(rawSongs: List<Song.Raw>)
suspend fun finalize(rawSongs: List<Song.Raw>)
/**
* Use the cache to populate the given [Song.Raw].
@ -54,6 +59,21 @@ interface CacheExtractor {
* [ExtractionResult.PARSED] is not returned.
*/
fun populate(rawSong: Song.Raw): ExtractionResult
companion object {
/**
* Create an instance with optional read-capacity.
* @param context [Context] required.
* @param readable Whether the new [CacheExtractor] should be able to read cached entries.
* @return A new [CacheExtractor] with the specified configuration.
*/
fun from(context: Context, readable: Boolean): CacheExtractor =
if (readable) {
ReadWriteCacheExtractor(context)
} else {
WriteOnlyCacheExtractor(context)
}
}
}
/**
@ -63,15 +83,18 @@ interface CacheExtractor {
* @see CacheExtractor
* @author Alexander Capehart (OxygenCobalt)
*/
open class WriteOnlyCacheExtractor(private val context: Context) : CacheExtractor {
override fun init() {
private open class WriteOnlyCacheExtractor(private val context: Context) : CacheExtractor {
protected val cacheDao: CacheDao by lazy { CacheDatabase.getInstance(context).cacheDao() }
override suspend fun init() {
// Nothing to do.
}
override fun finalize(rawSongs: List<Song.Raw>) {
override suspend fun finalize(rawSongs: List<Song.Raw>) {
try {
// Still write out whatever data was extracted.
CacheDatabase.getInstance(context).write(rawSongs)
cacheDao.nukeCache()
cacheDao.insertCache(rawSongs.map(CachedSong::fromRaw))
} catch (e: Exception) {
logE("Unable to save cache database.")
logE(e.stackTraceToString())
@ -89,22 +112,28 @@ open class WriteOnlyCacheExtractor(private val context: Context) : CacheExtracto
* @see CacheExtractor
* @author Alexander Capehart
*/
class ReadWriteCacheExtractor(private val context: Context) : WriteOnlyCacheExtractor(context) {
private var cacheMap: Map<Long, Song.Raw>? = null
private class ReadWriteCacheExtractor(private val context: Context) :
WriteOnlyCacheExtractor(context) {
private var cacheMap: Map<Long, CachedSong>? = null
private var invalidate = false
override fun init() {
override suspend fun init() {
try {
// Faster to load the whole database into memory than do a query on each
// populate call.
cacheMap = CacheDatabase.getInstance(context).read()
val cache = cacheDao.readCache()
cacheMap = buildMap {
for (cachedSong in cache) {
put(cachedSong.mediaStoreId, cachedSong)
}
}
} catch (e: Exception) {
logE("Unable to load cache database.")
logE(e.stackTraceToString())
}
}
override fun finalize(rawSongs: List<Song.Raw>) {
override suspend fun finalize(rawSongs: List<Song.Raw>) {
cacheMap = null
// Same some time by not re-writing the cache if we were able to create the entire
// library from it. If there is even just one song we could not populate from the
@ -122,38 +151,11 @@ class ReadWriteCacheExtractor(private val context: Context) : WriteOnlyCacheExtr
// 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 cachedRawSong = map[rawSong.mediaStoreId]
if (cachedRawSong != null &&
cachedRawSong.dateAdded == rawSong.dateAdded &&
cachedRawSong.dateModified == rawSong.dateModified) {
// No built-in "copy from" method for data classes, just have to assign
// the data ourselves.
rawSong.musicBrainzId = cachedRawSong.musicBrainzId
rawSong.name = cachedRawSong.name
rawSong.sortName = cachedRawSong.sortName
rawSong.size = cachedRawSong.size
rawSong.durationMs = cachedRawSong.durationMs
rawSong.track = cachedRawSong.track
rawSong.disc = cachedRawSong.disc
rawSong.date = cachedRawSong.date
rawSong.albumMusicBrainzId = cachedRawSong.albumMusicBrainzId
rawSong.albumName = cachedRawSong.albumName
rawSong.albumSortName = cachedRawSong.albumSortName
rawSong.releaseTypes = cachedRawSong.releaseTypes
rawSong.artistMusicBrainzIds = cachedRawSong.artistMusicBrainzIds
rawSong.artistNames = cachedRawSong.artistNames
rawSong.artistSortNames = cachedRawSong.artistSortNames
rawSong.albumArtistMusicBrainzIds = cachedRawSong.albumArtistMusicBrainzIds
rawSong.albumArtistNames = cachedRawSong.albumArtistNames
rawSong.albumArtistSortNames = cachedRawSong.albumArtistSortNames
rawSong.genreNames = cachedRawSong.genreNames
val cachedSong = map[rawSong.mediaStoreId]
if (cachedSong != null &&
cachedSong.dateAdded == rawSong.dateAdded &&
cachedSong.dateModified == rawSong.dateModified) {
cachedSong.copyToRaw(rawSong)
return ExtractionResult.CACHED
}
@ -164,311 +166,170 @@ class ReadWriteCacheExtractor(private val context: Context) : WriteOnlyCacheExtr
}
}
/**
* Internal [Song.Raw] cache database.
* @author Alexander Capehart (OxygenCobalt)
* @see [CacheExtractor]
*/
private class CacheDatabase(context: Context) :
SQLiteOpenHelper(context, DB_NAME, null, DB_VERSION) {
override fun onCreate(db: SQLiteDatabase) {
// Map the cacheable raw song fields to database fields. Cache-able in this context
// means information independent of the file-system, excluding IDs and timestamps required
// to retrieve items from the cache.
db.createTable(TABLE_RAW_SONGS) {
append("${Columns.MEDIA_STORE_ID} LONG PRIMARY KEY,")
append("${Columns.DATE_ADDED} LONG NOT NULL,")
append("${Columns.DATE_MODIFIED} LONG NOT NULL,")
append("${Columns.SIZE} LONG NOT NULL,")
append("${Columns.DURATION} LONG NOT NULL,")
append("${Columns.MUSIC_BRAINZ_ID} STRING,")
append("${Columns.NAME} STRING NOT NULL,")
append("${Columns.SORT_NAME} STRING,")
append("${Columns.TRACK} INT,")
append("${Columns.DISC} INT,")
append("${Columns.SUBTITLE} STRING,")
append("${Columns.DATE} STRING,")
append("${Columns.ALBUM_MUSIC_BRAINZ_ID} STRING,")
append("${Columns.ALBUM_NAME} STRING NOT NULL,")
append("${Columns.ALBUM_SORT_NAME} STRING,")
append("${Columns.RELEASE_TYPES} STRING,")
append("${Columns.ARTIST_MUSIC_BRAINZ_IDS} STRING,")
append("${Columns.ARTIST_NAMES} STRING,")
append("${Columns.ARTIST_SORT_NAMES} STRING,")
append("${Columns.ALBUM_ARTIST_MUSIC_BRAINZ_IDS} STRING,")
append("${Columns.ALBUM_ARTIST_NAMES} STRING,")
append("${Columns.ALBUM_ARTIST_SORT_NAMES} STRING,")
append("${Columns.GENRE_NAMES} STRING")
}
}
override fun onUpgrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) = nuke(db)
override fun onDowngrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) = nuke(db)
private fun nuke(db: SQLiteDatabase) {
// No cost to nuking this database, only causes higher loading times.
logD("Nuking database")
db.apply {
execSQL("DROP TABLE IF EXISTS $TABLE_RAW_SONGS")
onCreate(this)
}
}
/**
* Read out this database into memory.
* @return A mapping between the MediaStore IDs of the cache entries and a [Song.Raw] containing
* the cacheable data for the entry. Note that any filesystem-dependent information (excluding
* IDs and timestamps) is not cached.
*/
fun read(): Map<Long, Song.Raw> {
requireBackgroundThread()
val start = System.currentTimeMillis()
val map = mutableMapOf<Long, Song.Raw>()
readableDatabase.queryAll(TABLE_RAW_SONGS) { cursor ->
if (cursor.count == 0) {
// Nothing to do.
return@queryAll
}
val idIndex = cursor.getColumnIndexOrThrow(Columns.MEDIA_STORE_ID)
val dateAddedIndex = cursor.getColumnIndexOrThrow(Columns.DATE_ADDED)
val dateModifiedIndex = cursor.getColumnIndexOrThrow(Columns.DATE_MODIFIED)
val sizeIndex = cursor.getColumnIndexOrThrow(Columns.SIZE)
val durationIndex = cursor.getColumnIndexOrThrow(Columns.DURATION)
val musicBrainzIdIndex = cursor.getColumnIndexOrThrow(Columns.MUSIC_BRAINZ_ID)
val nameIndex = cursor.getColumnIndexOrThrow(Columns.NAME)
val sortNameIndex = cursor.getColumnIndexOrThrow(Columns.SORT_NAME)
val trackIndex = cursor.getColumnIndexOrThrow(Columns.TRACK)
val discIndex = cursor.getColumnIndexOrThrow(Columns.DISC)
val subtitleIndex = cursor.getColumnIndex(Columns.SUBTITLE)
val dateIndex = cursor.getColumnIndexOrThrow(Columns.DATE)
val albumMusicBrainzIdIndex =
cursor.getColumnIndexOrThrow(Columns.ALBUM_MUSIC_BRAINZ_ID)
val albumNameIndex = cursor.getColumnIndexOrThrow(Columns.ALBUM_NAME)
val albumSortNameIndex = cursor.getColumnIndexOrThrow(Columns.ALBUM_SORT_NAME)
val releaseTypesIndex = cursor.getColumnIndexOrThrow(Columns.RELEASE_TYPES)
val artistMusicBrainzIdsIndex =
cursor.getColumnIndexOrThrow(Columns.ARTIST_MUSIC_BRAINZ_IDS)
val artistNamesIndex = cursor.getColumnIndexOrThrow(Columns.ARTIST_NAMES)
val artistSortNamesIndex = cursor.getColumnIndexOrThrow(Columns.ARTIST_SORT_NAMES)
val albumArtistMusicBrainzIdsIndex =
cursor.getColumnIndexOrThrow(Columns.ALBUM_ARTIST_MUSIC_BRAINZ_IDS)
val albumArtistNamesIndex = cursor.getColumnIndexOrThrow(Columns.ALBUM_ARTIST_NAMES)
val albumArtistSortNamesIndex =
cursor.getColumnIndexOrThrow(Columns.ALBUM_ARTIST_SORT_NAMES)
val genresIndex = cursor.getColumnIndexOrThrow(Columns.GENRE_NAMES)
while (cursor.moveToNext()) {
val raw = Song.Raw()
val id = cursor.getLong(idIndex)
raw.mediaStoreId = id
raw.dateAdded = cursor.getLong(dateAddedIndex)
raw.dateModified = cursor.getLong(dateModifiedIndex)
raw.size = cursor.getLong(sizeIndex)
raw.durationMs = cursor.getLong(durationIndex)
raw.musicBrainzId = cursor.getStringOrNull(musicBrainzIdIndex)
raw.name = cursor.getString(nameIndex)
raw.sortName = cursor.getStringOrNull(sortNameIndex)
raw.track = cursor.getIntOrNull(trackIndex)
raw.disc = cursor.getIntOrNull(discIndex)
raw.subtitle = cursor.getStringOrNull(subtitleIndex)
raw.date = cursor.getStringOrNull(dateIndex)?.let(Date::from)
raw.albumMusicBrainzId = cursor.getStringOrNull(albumMusicBrainzIdIndex)
raw.albumName = cursor.getString(albumNameIndex)
raw.albumSortName = cursor.getStringOrNull(albumSortNameIndex)
cursor.getStringOrNull(releaseTypesIndex)?.let {
raw.releaseTypes = it.parseSQLMultiValue()
}
cursor.getStringOrNull(artistMusicBrainzIdsIndex)?.let {
raw.artistMusicBrainzIds = it.parseSQLMultiValue()
}
cursor.getStringOrNull(artistNamesIndex)?.let {
raw.artistNames = it.parseSQLMultiValue()
}
cursor.getStringOrNull(artistSortNamesIndex)?.let {
raw.artistSortNames = it.parseSQLMultiValue()
}
cursor.getStringOrNull(albumArtistMusicBrainzIdsIndex)?.let {
raw.albumArtistMusicBrainzIds = it.parseSQLMultiValue()
}
cursor.getStringOrNull(albumArtistNamesIndex)?.let {
raw.albumArtistNames = it.parseSQLMultiValue()
}
cursor.getStringOrNull(albumArtistSortNamesIndex)?.let {
raw.albumArtistSortNames = it.parseSQLMultiValue()
}
cursor.getStringOrNull(genresIndex)?.let {
raw.genreNames = it.parseSQLMultiValue()
}
map[id] = raw
}
}
logD("Read cache in ${System.currentTimeMillis() - start}ms")
return map
}
/**
* Write a new list of [Song.Raw] to this database.
* @param rawSongs The new [Song.Raw] instances to cache. Note that any filesystem-dependent
* information (excluding IDs and timestamps) is not cached.
*/
fun write(rawSongs: List<Song.Raw>) {
val start = System.currentTimeMillis()
writableDatabase.writeList(rawSongs, TABLE_RAW_SONGS) { _, rawSong ->
ContentValues(22).apply {
put(Columns.MEDIA_STORE_ID, rawSong.mediaStoreId)
put(Columns.DATE_ADDED, rawSong.dateAdded)
put(Columns.DATE_MODIFIED, rawSong.dateModified)
put(Columns.SIZE, rawSong.size)
put(Columns.DURATION, rawSong.durationMs)
put(Columns.MUSIC_BRAINZ_ID, rawSong.musicBrainzId)
put(Columns.NAME, rawSong.name)
put(Columns.SORT_NAME, rawSong.sortName)
put(Columns.TRACK, rawSong.track)
put(Columns.DISC, rawSong.disc)
put(Columns.SUBTITLE, rawSong.subtitle)
put(Columns.DATE, rawSong.date?.toString())
put(Columns.ALBUM_MUSIC_BRAINZ_ID, rawSong.albumMusicBrainzId)
put(Columns.ALBUM_NAME, rawSong.albumName)
put(Columns.ALBUM_SORT_NAME, rawSong.albumSortName)
put(Columns.RELEASE_TYPES, rawSong.releaseTypes.toSQLMultiValue())
put(Columns.ARTIST_MUSIC_BRAINZ_IDS, rawSong.artistMusicBrainzIds.toSQLMultiValue())
put(Columns.ARTIST_NAMES, rawSong.artistNames.toSQLMultiValue())
put(Columns.ARTIST_SORT_NAMES, rawSong.artistSortNames.toSQLMultiValue())
put(
Columns.ALBUM_ARTIST_MUSIC_BRAINZ_IDS,
rawSong.albumArtistMusicBrainzIds.toSQLMultiValue())
put(Columns.ALBUM_ARTIST_NAMES, rawSong.albumArtistNames.toSQLMultiValue())
put(Columns.ALBUM_ARTIST_SORT_NAMES, rawSong.albumArtistSortNames.toSQLMultiValue())
put(Columns.GENRE_NAMES, rawSong.genreNames.toSQLMultiValue())
}
}
logD("Wrote cache in ${System.currentTimeMillis() - start}ms")
}
// SQLite does not natively support multiple values, so we have to serialize multi-value
// tags with separators. Not ideal, but nothing we can do.
/**
* Transforms the multi-string list into a SQL-safe multi-string value.
* @return A single string containing all values within the multi-string list, delimited by a
* ";". Pre-existing ";" characters will be escaped.
*/
private fun List<String>.toSQLMultiValue() =
if (isNotEmpty()) {
joinToString(";") { it.replace(";", "\\;") }
} else {
null
}
/**
* Transforms the SQL-safe multi-string value into a multi-string list.
* @return A list of strings corresponding to the delimited values present within the original
* string. Escaped delimiters are converted back into their normal forms.
*/
private fun String.parseSQLMultiValue() = splitEscaped { it == ';' }.correctWhitespace()
/** Defines the columns used in this database. */
private object Columns {
/** @see Song.Raw.mediaStoreId */
const val MEDIA_STORE_ID = "msid"
/** @see Song.Raw.dateAdded */
const val DATE_ADDED = "date_added"
/** @see Song.Raw.dateModified */
const val DATE_MODIFIED = "date_modified"
/** @see Song.Raw.size */
const val SIZE = "size"
/** @see Song.Raw.durationMs */
const val DURATION = "duration"
/** @see Song.Raw.musicBrainzId */
const val MUSIC_BRAINZ_ID = "mbid"
/** @see Song.Raw.name */
const val NAME = "name"
/** @see Song.Raw.sortName */
const val SORT_NAME = "sort_name"
/** @see Song.Raw.track */
const val TRACK = "track"
/** @see Song.Raw.disc */
const val DISC = "disc"
/** @see Song.Raw.subtitle */
const val SUBTITLE = "subtitle"
/** @see Song.Raw.date */
const val DATE = "date"
/** @see Song.Raw.albumMusicBrainzId */
const val ALBUM_MUSIC_BRAINZ_ID = "album_mbid"
/** @see Song.Raw.albumName */
const val ALBUM_NAME = "album"
/** @see Song.Raw.albumSortName */
const val ALBUM_SORT_NAME = "album_sort"
/** @see Song.Raw.releaseTypes */
const val RELEASE_TYPES = "album_types"
/** @see Song.Raw.artistMusicBrainzIds */
const val ARTIST_MUSIC_BRAINZ_IDS = "artists_mbid"
/** @see Song.Raw.artistNames */
const val ARTIST_NAMES = "artists"
/** @see Song.Raw.artistSortNames */
const val ARTIST_SORT_NAMES = "artists_sort"
/** @see Song.Raw.albumArtistMusicBrainzIds */
const val ALBUM_ARTIST_MUSIC_BRAINZ_IDS = "album_artists_mbid"
/** @see Song.Raw.albumArtistNames */
const val ALBUM_ARTIST_NAMES = "album_artists"
/** @see Song.Raw.albumArtistSortNames */
const val ALBUM_ARTIST_SORT_NAMES = "album_artists_sort"
/** @see Song.Raw.genreNames */
const val GENRE_NAMES = "genres"
}
@Database(entities = [CachedSong::class], version = 27, exportSchema = false)
private abstract class CacheDatabase : RoomDatabase() {
abstract fun cacheDao(): CacheDao
companion object {
private const val DB_NAME = "auxio_music_cache.db"
private const val DB_VERSION = 3
private const val TABLE_RAW_SONGS = "raw_songs"
@Volatile private var INSTANCE: CacheDatabase? = null
/**
* Get a singleton instance.
* @return The (possibly newly-created) singleton instance.
* Get/create the shared instance of this database.
* @param context [Context] required.
*/
fun getInstance(context: Context): CacheDatabase {
val currentInstance = INSTANCE
if (currentInstance != null) {
return currentInstance
val instance = INSTANCE
if (instance != null) {
return instance
}
synchronized(this) {
val newInstance = CacheDatabase(context.applicationContext)
val newInstance =
Room.databaseBuilder(
context, CacheDatabase::class.java, "auxio_metadata_cache.db")
.fallbackToDestructiveMigration()
.fallbackToDestructiveMigrationFrom(0)
.fallbackToDestructiveMigrationOnDowngrade()
.build()
INSTANCE = newInstance
return newInstance
}
}
}
}
@Dao
private interface CacheDao {
@Query("SELECT * FROM ${CachedSong.TABLE_NAME}") suspend fun readCache(): List<CachedSong>
@Query("DELETE FROM ${CachedSong.TABLE_NAME}") suspend fun nukeCache()
@Insert suspend fun insertCache(songs: List<CachedSong>)
}
@Entity(tableName = CachedSong.TABLE_NAME)
@TypeConverters(CachedSong.Converters::class)
private 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.
*/
@PrimaryKey var mediaStoreId: Long,
/** @see Song.dateAdded */
var dateAdded: Long,
/** The latest date the [Song]'s audio file was modified, as a unix epoch timestamp. */
var dateModified: Long,
/** @see Song.size */
var size: Long? = null,
/** @see Song.durationMs */
var durationMs: Long,
/** @see Music.UID */
var musicBrainzId: String? = null,
/** @see Music.rawName */
var name: String,
/** @see Music.rawSortName */
var sortName: String? = null,
/** @see Song.track */
var track: Int? = null,
/** @see Disc.number */
var disc: Int? = null,
/** @See Disc.name */
var subtitle: String? = null,
/** @see Song.date */
var date: Date? = null,
/** @see Album.Raw.musicBrainzId */
var albumMusicBrainzId: String? = null,
/** @see Album.Raw.name */
var albumName: String,
/** @see Album.Raw.sortName */
var albumSortName: String? = null,
/** @see Album.Raw.releaseType */
var releaseTypes: List<String> = listOf(),
/** @see Artist.Raw.musicBrainzId */
var artistMusicBrainzIds: List<String> = listOf(),
/** @see Artist.Raw.name */
var artistNames: List<String> = listOf(),
/** @see Artist.Raw.sortName */
var artistSortNames: List<String> = listOf(),
/** @see Artist.Raw.musicBrainzId */
var albumArtistMusicBrainzIds: List<String> = listOf(),
/** @see Artist.Raw.name */
var albumArtistNames: List<String> = listOf(),
/** @see Artist.Raw.sortName */
var albumArtistSortNames: List<String> = listOf(),
/** @see Genre.Raw.name */
var genreNames: List<String> = listOf()
) {
fun copyToRaw(rawSong: Song.Raw): CachedSong {
rawSong.musicBrainzId = musicBrainzId
rawSong.name = name
rawSong.sortName = sortName
rawSong.size = size
rawSong.durationMs = durationMs
rawSong.track = track
rawSong.disc = disc
rawSong.date = date
rawSong.albumMusicBrainzId = albumMusicBrainzId
rawSong.albumName = albumName
rawSong.albumSortName = albumSortName
rawSong.releaseTypes = releaseTypes
rawSong.artistMusicBrainzIds = artistMusicBrainzIds
rawSong.artistNames = artistNames
rawSong.artistSortNames = artistSortNames
rawSong.albumArtistMusicBrainzIds = albumArtistMusicBrainzIds
rawSong.albumArtistNames = albumArtistNames
rawSong.albumArtistSortNames = albumArtistSortNames
rawSong.genreNames = genreNames
return this
}
object Converters {
@TypeConverter
fun fromMultiValue(values: List<String>) =
values.joinToString(";") { it.replace(";", "\\;") }
@TypeConverter
fun toMultiValue(string: String) = string.splitEscaped { it == ';' }.correctWhitespace()
@TypeConverter fun fromDate(date: Date?) = date?.toString()
@TypeConverter fun toDate(string: String?) = string?.let(Date::from)
}
companion object {
const val TABLE_NAME = "cached_songs"
fun fromRaw(rawSong: Song.Raw) =
CachedSong(
mediaStoreId =
requireNotNull(rawSong.mediaStoreId) { "Invalid raw: No MediaStore ID" },
dateAdded = requireNotNull(rawSong.dateAdded) { "Invalid raw: No date added" },
dateModified =
requireNotNull(rawSong.dateModified) { "Invalid raw: No date modified" },
musicBrainzId = rawSong.musicBrainzId,
name = requireNotNull(rawSong.name) { "Invalid raw: No name" },
sortName = rawSong.sortName,
size = rawSong.size,
durationMs = requireNotNull(rawSong.durationMs) { "Invalid raw: No duration" },
track = rawSong.track,
disc = rawSong.disc,
date = rawSong.date,
albumMusicBrainzId = rawSong.albumMusicBrainzId,
albumName = requireNotNull(rawSong.albumName) { "Invalid raw: No album name" },
albumSortName = rawSong.albumSortName,
releaseTypes = rawSong.releaseTypes,
artistMusicBrainzIds = rawSong.artistMusicBrainzIds,
artistNames = rawSong.artistNames,
artistSortNames = rawSong.artistSortNames,
albumArtistMusicBrainzIds = rawSong.albumArtistMusicBrainzIds,
albumArtistNames = rawSong.albumArtistNames,
albumArtistSortNames = rawSong.albumArtistSortNames,
genreNames = rawSong.genreNames)
}
}

View file

@ -29,6 +29,7 @@ import androidx.core.database.getStringOrNull
import java.io.File
import org.oxycblt.auxio.music.MusicSettings
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.music.format.Date
import org.oxycblt.auxio.music.parsing.parseId3v2PositionField
import org.oxycblt.auxio.music.parsing.transformPositionField
import org.oxycblt.auxio.music.storage.Directory
@ -38,7 +39,6 @@ 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.tags.Date
import org.oxycblt.auxio.util.getSystemServiceCompat
import org.oxycblt.auxio.util.logD
@ -83,7 +83,7 @@ abstract class MediaStoreExtractor(
* the media database for music files.
* @return A [Cursor] of the music data returned from the database.
*/
open fun init(): Cursor {
open suspend fun init(): Cursor {
val start = System.currentTimeMillis()
cacheExtractor.init()
val musicSettings = MusicSettings.from(context)
@ -195,7 +195,7 @@ abstract class MediaStoreExtractor(
* freeing up memory.
* @param rawSongs The songs to write into the cache.
*/
fun finalize(rawSongs: List<Song.Raw>) {
open suspend fun finalize(rawSongs: List<Song.Raw>) {
// Free the cursor (and it's resources)
cursor?.close()
cursor = null
@ -325,12 +325,27 @@ abstract class MediaStoreExtractor(
genreNamesMap[raw.mediaStoreId]?.let { raw.genreNames = listOf(it) }
}
private companion object {
companion object {
/**
* Create a framework-backed instance.
* @param context [Context] required.
* @param cacheExtractor [CacheExtractor] to wrap.
* @return A new [MediaStoreExtractor] that will work best on the device's API level.
*/
fun from(context: Context, cacheExtractor: CacheExtractor) =
when {
Build.VERSION.SDK_INT >= Build.VERSION_CODES.R ->
Api30MediaStoreExtractor(context, cacheExtractor)
Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q ->
Api29MediaStoreExtractor(context, cacheExtractor)
else -> Api21MediaStoreExtractor(context, cacheExtractor)
}
/**
* The base selector that works across all versions of android. Does not exclude
* directories.
*/
const val BASE_SELECTOR = "NOT ${MediaStore.Audio.Media.SIZE}=0"
private const val BASE_SELECTOR = "NOT ${MediaStore.Audio.Media.SIZE}=0"
/**
* The album artist of a song. This column has existed since at least API 21, but until API
@ -338,13 +353,13 @@ abstract class MediaStoreExtractor(
* versions that Auxio supports.
*/
@Suppress("InlinedApi")
const val AUDIO_COLUMN_ALBUM_ARTIST = MediaStore.Audio.AudioColumns.ALBUM_ARTIST
private const val AUDIO_COLUMN_ALBUM_ARTIST = MediaStore.Audio.AudioColumns.ALBUM_ARTIST
/**
* The external volume. This naming has existed since API 21, but no constant existed for it
* until API 29. This will work on all versions that Auxio supports.
*/
@Suppress("InlinedApi") const val VOLUME_EXTERNAL = MediaStore.VOLUME_EXTERNAL
@Suppress("InlinedApi") private const val VOLUME_EXTERNAL = MediaStore.VOLUME_EXTERNAL
}
}
@ -358,12 +373,12 @@ abstract class MediaStoreExtractor(
* @param cacheExtractor [CacheExtractor] implementation for cache optimizations.
* @author Alexander Capehart (OxygenCobalt)
*/
class Api21MediaStoreExtractor(context: Context, cacheExtractor: CacheExtractor) :
private class Api21MediaStoreExtractor(context: Context, cacheExtractor: CacheExtractor) :
MediaStoreExtractor(context, cacheExtractor) {
private var trackIndex = -1
private var dataIndex = -1
override fun init(): Cursor {
override suspend fun init(): Cursor {
val cursor = super.init()
// Set up cursor indices for later use.
trackIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.TRACK)
@ -438,12 +453,12 @@ class Api21MediaStoreExtractor(context: Context, cacheExtractor: CacheExtractor)
* @author Alexander Capehart (OxygenCobalt)
*/
@RequiresApi(Build.VERSION_CODES.Q)
open class BaseApi29MediaStoreExtractor(context: Context, cacheExtractor: CacheExtractor) :
private open class BaseApi29MediaStoreExtractor(context: Context, cacheExtractor: CacheExtractor) :
MediaStoreExtractor(context, cacheExtractor) {
private var volumeIndex = -1
private var relativePathIndex = -1
override fun init(): Cursor {
override suspend fun init(): Cursor {
val cursor = super.init()
// Set up cursor indices for later use.
volumeIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.VOLUME_NAME)
@ -501,11 +516,11 @@ open class BaseApi29MediaStoreExtractor(context: Context, cacheExtractor: CacheE
* @author Alexander Capehart (OxygenCobalt)
*/
@RequiresApi(Build.VERSION_CODES.Q)
open class Api29MediaStoreExtractor(context: Context, cacheExtractor: CacheExtractor) :
private open class Api29MediaStoreExtractor(context: Context, cacheExtractor: CacheExtractor) :
BaseApi29MediaStoreExtractor(context, cacheExtractor) {
private var trackIndex = -1
override fun init(): Cursor {
override suspend fun init(): Cursor {
val cursor = super.init()
// Set up cursor indices for later use.
trackIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.TRACK)
@ -536,12 +551,12 @@ open class Api29MediaStoreExtractor(context: Context, cacheExtractor: CacheExtra
* @author Alexander Capehart (OxygenCobalt)
*/
@RequiresApi(Build.VERSION_CODES.R)
class Api30MediaStoreExtractor(context: Context, cacheExtractor: CacheExtractor) :
private class Api30MediaStoreExtractor(context: Context, cacheExtractor: CacheExtractor) :
BaseApi29MediaStoreExtractor(context, cacheExtractor) {
private var trackIndex: Int = -1
private var discIndex: Int = -1
override fun init(): Cursor {
override suspend fun init(): Cursor {
val cursor = super.init()
// Set up cursor indices for later use.
trackIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.CD_TRACK_NUMBER)

View file

@ -23,10 +23,11 @@ import com.google.android.exoplayer2.MediaItem
import com.google.android.exoplayer2.MetadataRetriever
import kotlinx.coroutines.flow.flow
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.music.format.Date
import org.oxycblt.auxio.music.format.TextTags
import org.oxycblt.auxio.music.parsing.parseId3v2PositionField
import org.oxycblt.auxio.music.parsing.parseVorbisPositionField
import org.oxycblt.auxio.music.storage.toAudioUri
import org.oxycblt.auxio.music.tags.Date
import org.oxycblt.auxio.util.logD
import org.oxycblt.auxio.util.logW
@ -53,14 +54,14 @@ class MetadataExtractor(
* relies on.
* @return The amount of music that is expected to be loaded.
*/
fun init() = mediaStoreExtractor.init().count
suspend fun init() = mediaStoreExtractor.init().count
/**
* Finalize the Extractor by writing the newly-loaded [Song.Raw]s back into the cache, alongside
* freeing up memory.
* @param rawSongs The songs to write into the cache.
*/
fun finalize(rawSongs: List<Song.Raw>) = mediaStoreExtractor.finalize(rawSongs)
suspend fun finalize(rawSongs: List<Song.Raw>) = mediaStoreExtractor.finalize(rawSongs)
/**
* Returns a flow that parses all [Song.Raw] instances queued by the sub-extractors. This will

View file

@ -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.format
import android.content.Context
import android.media.MediaExtractor

View file

@ -15,7 +15,7 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.oxycblt.auxio.music.tags
package org.oxycblt.auxio.music.format
import android.content.Context
import java.text.ParseException

View file

@ -15,7 +15,7 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.oxycblt.auxio.music.tags
package org.oxycblt.auxio.music.format
import org.oxycblt.auxio.list.Item

View file

@ -15,7 +15,7 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.oxycblt.auxio.music.tags
package org.oxycblt.auxio.music.format
import org.oxycblt.auxio.R

View file

@ -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.format
import com.google.android.exoplayer2.metadata.Metadata
import com.google.android.exoplayer2.metadata.id3.InternalFrame

View file

@ -22,9 +22,9 @@ import kotlin.math.max
import org.oxycblt.auxio.IntegerTable
import org.oxycblt.auxio.R
import org.oxycblt.auxio.music.*
import org.oxycblt.auxio.music.format.Date
import org.oxycblt.auxio.music.format.Disc
import org.oxycblt.auxio.music.library.Sort.Mode
import org.oxycblt.auxio.music.tags.Date
import org.oxycblt.auxio.music.tags.Disc
/**
* A sorting method.

View file

@ -24,6 +24,7 @@ import android.os.Build
import androidx.core.content.ContextCompat
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.withContext
import kotlinx.coroutines.yield
import org.oxycblt.auxio.BuildConfig
@ -205,20 +206,8 @@ class Indexer private constructor() {
// Create the chain of extractors. Each extractor builds on the previous and
// enables version-specific features in order to create the best possible music
// experience.
val cacheDatabase =
if (withCache) {
ReadWriteCacheExtractor(context)
} else {
WriteOnlyCacheExtractor(context)
}
val mediaStoreExtractor =
when {
Build.VERSION.SDK_INT >= Build.VERSION_CODES.R ->
Api30MediaStoreExtractor(context, cacheDatabase)
Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q ->
Api29MediaStoreExtractor(context, cacheDatabase)
else -> Api21MediaStoreExtractor(context, cacheDatabase)
}
val cacheExtractor = CacheExtractor.from(context, withCache)
val mediaStoreExtractor = MediaStoreExtractor.from(context, cacheExtractor)
val metadataExtractor = MetadataExtractor(context, mediaStoreExtractor)
val rawSongs = loadRawSongs(metadataExtractor).ifEmpty { throw NoMusicException() }
// Build the rest of the music library from the song list. This is much more powerful

View file

@ -19,7 +19,6 @@ package org.oxycblt.auxio.playback.persist
import androidx.room.TypeConverter
import org.oxycblt.auxio.music.Music
import org.oxycblt.auxio.playback.state.RepeatMode
/**
* Defines conversions used in the persistence table.
@ -29,6 +28,6 @@ object PersistenceConverters {
/** @see [Music.UID.toString] */
@TypeConverter fun fromMusicUID(uid: Music.UID?) = uid?.toString()
/** @see [Music.UID.fromString]*/
/** @see [Music.UID.fromString] */
@TypeConverter fun toMusicUid(string: String?) = string?.let(Music.UID::fromString)
}

View file

@ -37,7 +37,7 @@ import org.oxycblt.auxio.playback.state.RepeatMode
*/
@Database(
entities = [PlaybackState::class, QueueHeapItem::class, QueueMappingItem::class],
version = 1,
version = 27,
exportSchema = false)
@TypeConverters(PersistenceConverters::class)
abstract class PersistenceDatabase : RoomDatabase() {

View file

@ -28,7 +28,7 @@ import com.google.android.exoplayer2.util.MimeTypes
import java.nio.ByteBuffer
import kotlin.math.pow
import org.oxycblt.auxio.music.Album
import org.oxycblt.auxio.music.extractor.TextTags
import org.oxycblt.auxio.music.format.TextTags
import org.oxycblt.auxio.playback.PlaybackSettings
import org.oxycblt.auxio.playback.state.PlaybackStateManager
import org.oxycblt.auxio.util.logD

View file

@ -1,81 +0,0 @@
/*
* Copyright (c) 2022 Auxio Project
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.oxycblt.auxio.util
import android.content.ContentValues
import android.database.Cursor
import android.database.sqlite.SQLiteDatabase
import androidx.core.database.sqlite.transaction
/**
* Query all columns in the given [SQLiteDatabase] table, running the block when the [Cursor] is
* loaded. The block will be called with [use], allowing for automatic cleanup of [Cursor]
* resources.
* @param tableName The name of the table to query all columns in.
* @param block The code block to run with the loaded [Cursor].
*/
inline fun <R> SQLiteDatabase.queryAll(tableName: String, block: (Cursor) -> R) =
query(tableName, null, null, null, null, null, null)?.use(block)
/**
* Create a table in an [SQLiteDatabase], if it does not already exist.
* @param name The name of the table to create.
* @param schema A block that adds a comma-separated list of SQL column declarations.
*/
inline fun SQLiteDatabase.createTable(name: String, schema: StringBuilder.() -> StringBuilder) {
val command = StringBuilder().append("CREATE TABLE IF NOT EXISTS $name(").schema().append(")")
execSQL(command.toString())
}
/**
* Safely write a list of items to an [SQLiteDatabase]. This will clear the prior list and write as
* much of the new list as possible.
* @param list The list of items to write.
* @param tableName The name of the table to write the items to.
* @param transform Code to transform an item into a corresponding [ContentValues] to the given
* table.
*/
inline fun <reified T> SQLiteDatabase.writeList(
list: List<T>,
tableName: String,
transform: (Int, T) -> ContentValues
) {
// Clear any prior items in the table.
transaction { delete(tableName, null, null) }
var transactionPosition = 0
while (transactionPosition < list.size) {
// Start at the current transaction position, if a transaction failed at any point,
// this value can be used to immediately start at the next item and continue writing
// the list without error.
var i = transactionPosition
transaction {
while (i < list.size) {
val values = transform(i, list[i])
// Increment forward now so that if this insert fails, the transaction position
// will still start at the next i.
i++
insert(tableName, null, values)
}
}
transactionPosition = i
logD(
"Wrote batch of ${T::class.simpleName} instances. " +
"Position is now at $transactionPosition")
}
}

View file

@ -21,7 +21,7 @@ import java.util.*
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import org.oxycblt.auxio.music.tags.Date
import org.oxycblt.auxio.music.format.Date
class MusicTest {
@Test

View file

@ -26,6 +26,7 @@ import com.google.android.exoplayer2.metadata.vorbis.VorbisComment
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import org.oxycblt.auxio.music.format.TextTags
class TextTagsTest {
@Test

View file

@ -20,6 +20,7 @@ package org.oxycblt.auxio.music.tags
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import org.oxycblt.auxio.music.format.Date
class DateTest {
@Test

View file

@ -20,6 +20,7 @@ package org.oxycblt.auxio.music.tags
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import org.oxycblt.auxio.music.format.Disc
class DiscTest {
@Test

View file

@ -19,6 +19,7 @@ package org.oxycblt.auxio.music.tags
import org.junit.Assert.assertEquals
import org.junit.Test
import org.oxycblt.auxio.music.format.ReleaseType
class ReleaseTypeTest {
@Test