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.list.Item
import org.oxycblt.auxio.music.* import org.oxycblt.auxio.music.*
import org.oxycblt.auxio.music.MusicStore 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.Library
import org.oxycblt.auxio.music.library.Sort 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.playback.PlaybackSettings
import org.oxycblt.auxio.util.* 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.R
import org.oxycblt.auxio.databinding.DialogSongDetailBinding import org.oxycblt.auxio.databinding.DialogSongDetailBinding
import org.oxycblt.auxio.music.Song 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.playback.formatDurationMs
import org.oxycblt.auxio.ui.ViewBindingDialogFragment import org.oxycblt.auxio.ui.ViewBindingDialogFragment
import org.oxycblt.auxio.util.androidActivityViewModels 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.list.adapter.SimpleDiffCallback
import org.oxycblt.auxio.music.Album import org.oxycblt.auxio.music.Album
import org.oxycblt.auxio.music.Song 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.playback.formatDurationMs
import org.oxycblt.auxio.util.context import org.oxycblt.auxio.util.context
import org.oxycblt.auxio.util.getPlural import org.oxycblt.auxio.util.getPlural

View file

@ -31,13 +31,13 @@ import kotlinx.parcelize.IgnoredOnParcel
import kotlinx.parcelize.Parcelize import kotlinx.parcelize.Parcelize
import org.oxycblt.auxio.R import org.oxycblt.auxio.R
import org.oxycblt.auxio.list.Item 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.library.Sort
import org.oxycblt.auxio.music.parsing.parseId3GenreNames import org.oxycblt.auxio.music.parsing.parseId3GenreNames
import org.oxycblt.auxio.music.parsing.parseMultiValue import org.oxycblt.auxio.music.parsing.parseMultiValue
import org.oxycblt.auxio.music.storage.* 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.nonZeroOrNull
import org.oxycblt.auxio.util.unlikelyToBeNull import org.oxycblt.auxio.util.unlikelyToBeNull

View file

@ -17,16 +17,21 @@
package org.oxycblt.auxio.music.extractor package org.oxycblt.auxio.music.extractor
import android.content.ContentValues
import android.content.Context import android.content.Context
import android.database.sqlite.SQLiteDatabase import androidx.room.Dao
import android.database.sqlite.SQLiteOpenHelper import androidx.room.Database
import androidx.core.database.getIntOrNull import androidx.room.Entity
import androidx.core.database.getStringOrNull 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.Song
import org.oxycblt.auxio.music.format.Date
import org.oxycblt.auxio.music.parsing.correctWhitespace import org.oxycblt.auxio.music.parsing.correctWhitespace
import org.oxycblt.auxio.music.parsing.splitEscaped import org.oxycblt.auxio.music.parsing.splitEscaped
import org.oxycblt.auxio.music.tags.Date
import org.oxycblt.auxio.util.* import org.oxycblt.auxio.util.*
/** /**
@ -37,14 +42,14 @@ import org.oxycblt.auxio.util.*
*/ */
interface CacheExtractor { interface CacheExtractor {
/** Initialize the Extractor by reading the cache data into memory. */ /** 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 * Finalize the Extractor by writing the newly-loaded [Song.Raw]s back into the cache, alongside
* freeing up memory. * freeing up memory.
* @param rawSongs The songs to write into the cache. * @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]. * Use the cache to populate the given [Song.Raw].
@ -54,6 +59,21 @@ interface CacheExtractor {
* [ExtractionResult.PARSED] is not returned. * [ExtractionResult.PARSED] is not returned.
*/ */
fun populate(rawSong: Song.Raw): ExtractionResult 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 * @see CacheExtractor
* @author Alexander Capehart (OxygenCobalt) * @author Alexander Capehart (OxygenCobalt)
*/ */
open class WriteOnlyCacheExtractor(private val context: Context) : CacheExtractor { private open class WriteOnlyCacheExtractor(private val context: Context) : CacheExtractor {
override fun init() { protected val cacheDao: CacheDao by lazy { CacheDatabase.getInstance(context).cacheDao() }
override suspend fun init() {
// Nothing to do. // Nothing to do.
} }
override fun finalize(rawSongs: List<Song.Raw>) { override suspend fun finalize(rawSongs: List<Song.Raw>) {
try { try {
// Still write out whatever data was extracted. // Still write out whatever data was extracted.
CacheDatabase.getInstance(context).write(rawSongs) cacheDao.nukeCache()
cacheDao.insertCache(rawSongs.map(CachedSong::fromRaw))
} catch (e: Exception) { } catch (e: Exception) {
logE("Unable to save cache database.") logE("Unable to save cache database.")
logE(e.stackTraceToString()) logE(e.stackTraceToString())
@ -89,22 +112,28 @@ open class WriteOnlyCacheExtractor(private val context: Context) : CacheExtracto
* @see CacheExtractor * @see CacheExtractor
* @author Alexander Capehart * @author Alexander Capehart
*/ */
class ReadWriteCacheExtractor(private val context: Context) : WriteOnlyCacheExtractor(context) { private class ReadWriteCacheExtractor(private val context: Context) :
private var cacheMap: Map<Long, Song.Raw>? = null WriteOnlyCacheExtractor(context) {
private var cacheMap: Map<Long, CachedSong>? = null
private var invalidate = false private var invalidate = false
override fun init() { override suspend fun init() {
try { try {
// Faster to load the whole database into memory than do a query on each // Faster to load the whole database into memory than do a query on each
// populate call. // populate call.
cacheMap = CacheDatabase.getInstance(context).read() val cache = cacheDao.readCache()
cacheMap = buildMap {
for (cachedSong in cache) {
put(cachedSong.mediaStoreId, cachedSong)
}
}
} catch (e: Exception) { } catch (e: Exception) {
logE("Unable to load cache database.") logE("Unable to load cache database.")
logE(e.stackTraceToString()) logE(e.stackTraceToString())
} }
} }
override fun finalize(rawSongs: List<Song.Raw>) { override suspend fun finalize(rawSongs: List<Song.Raw>) {
cacheMap = null cacheMap = null
// Same some time by not re-writing the cache if we were able to create the entire // 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 // 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 // addition and modification timestamps. Technically the addition timestamp doesn't
// exist, but to safeguard against possible OEM-specific timestamp incoherence, we // exist, but to safeguard against possible OEM-specific timestamp incoherence, we
// check for it anyway. // check for it anyway.
val cachedRawSong = map[rawSong.mediaStoreId] val cachedSong = map[rawSong.mediaStoreId]
if (cachedRawSong != null && if (cachedSong != null &&
cachedRawSong.dateAdded == rawSong.dateAdded && cachedSong.dateAdded == rawSong.dateAdded &&
cachedRawSong.dateModified == rawSong.dateModified) { cachedSong.dateModified == rawSong.dateModified) {
// No built-in "copy from" method for data classes, just have to assign cachedSong.copyToRaw(rawSong)
// 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
return ExtractionResult.CACHED return ExtractionResult.CACHED
} }
@ -164,311 +166,170 @@ class ReadWriteCacheExtractor(private val context: Context) : WriteOnlyCacheExtr
} }
} }
/** @Database(entities = [CachedSong::class], version = 27, exportSchema = false)
* Internal [Song.Raw] cache database. private abstract class CacheDatabase : RoomDatabase() {
* @author Alexander Capehart (OxygenCobalt) abstract fun cacheDao(): CacheDao
* @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"
}
companion object { 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 @Volatile private var INSTANCE: CacheDatabase? = null
/** /**
* Get a singleton instance. * Get/create the shared instance of this database.
* @return The (possibly newly-created) singleton instance. * @param context [Context] required.
*/ */
fun getInstance(context: Context): CacheDatabase { fun getInstance(context: Context): CacheDatabase {
val currentInstance = INSTANCE val instance = INSTANCE
if (instance != null) {
if (currentInstance != null) { return instance
return currentInstance
} }
synchronized(this) { 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 INSTANCE = newInstance
return 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 java.io.File
import org.oxycblt.auxio.music.MusicSettings import org.oxycblt.auxio.music.MusicSettings
import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.music.format.Date
import org.oxycblt.auxio.music.parsing.parseId3v2PositionField import org.oxycblt.auxio.music.parsing.parseId3v2PositionField
import org.oxycblt.auxio.music.parsing.transformPositionField import org.oxycblt.auxio.music.parsing.transformPositionField
import org.oxycblt.auxio.music.storage.Directory 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.safeQuery
import org.oxycblt.auxio.music.storage.storageVolumesCompat import org.oxycblt.auxio.music.storage.storageVolumesCompat
import org.oxycblt.auxio.music.storage.useQuery 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.getSystemServiceCompat
import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.util.logD
@ -83,7 +83,7 @@ abstract class MediaStoreExtractor(
* the media database for music files. * the media database for music files.
* @return A [Cursor] of the music data returned from the database. * @return A [Cursor] of the music data returned from the database.
*/ */
open fun init(): Cursor { open suspend fun init(): Cursor {
val start = System.currentTimeMillis() val start = System.currentTimeMillis()
cacheExtractor.init() cacheExtractor.init()
val musicSettings = MusicSettings.from(context) val musicSettings = MusicSettings.from(context)
@ -195,7 +195,7 @@ abstract class MediaStoreExtractor(
* freeing up memory. * freeing up memory.
* @param rawSongs The songs to write into the cache. * @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) // Free the cursor (and it's resources)
cursor?.close() cursor?.close()
cursor = null cursor = null
@ -325,12 +325,27 @@ abstract class MediaStoreExtractor(
genreNamesMap[raw.mediaStoreId]?.let { raw.genreNames = listOf(it) } 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 * The base selector that works across all versions of android. Does not exclude
* directories. * 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 * 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. * versions that Auxio supports.
*/ */
@Suppress("InlinedApi") @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 * 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. * 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. * @param cacheExtractor [CacheExtractor] implementation for cache optimizations.
* @author Alexander Capehart (OxygenCobalt) * @author Alexander Capehart (OxygenCobalt)
*/ */
class Api21MediaStoreExtractor(context: Context, cacheExtractor: CacheExtractor) : private class Api21MediaStoreExtractor(context: Context, cacheExtractor: CacheExtractor) :
MediaStoreExtractor(context, cacheExtractor) { MediaStoreExtractor(context, cacheExtractor) {
private var trackIndex = -1 private var trackIndex = -1
private var dataIndex = -1 private var dataIndex = -1
override fun init(): Cursor { override suspend fun init(): Cursor {
val cursor = super.init() val cursor = super.init()
// Set up cursor indices for later use. // Set up cursor indices for later use.
trackIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.TRACK) trackIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.TRACK)
@ -438,12 +453,12 @@ class Api21MediaStoreExtractor(context: Context, cacheExtractor: CacheExtractor)
* @author Alexander Capehart (OxygenCobalt) * @author Alexander Capehart (OxygenCobalt)
*/ */
@RequiresApi(Build.VERSION_CODES.Q) @RequiresApi(Build.VERSION_CODES.Q)
open class BaseApi29MediaStoreExtractor(context: Context, cacheExtractor: CacheExtractor) : private open class BaseApi29MediaStoreExtractor(context: Context, cacheExtractor: CacheExtractor) :
MediaStoreExtractor(context, cacheExtractor) { MediaStoreExtractor(context, cacheExtractor) {
private var volumeIndex = -1 private var volumeIndex = -1
private var relativePathIndex = -1 private var relativePathIndex = -1
override fun init(): Cursor { override suspend fun init(): Cursor {
val cursor = super.init() val cursor = super.init()
// Set up cursor indices for later use. // Set up cursor indices for later use.
volumeIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.VOLUME_NAME) volumeIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.VOLUME_NAME)
@ -501,11 +516,11 @@ open class BaseApi29MediaStoreExtractor(context: Context, cacheExtractor: CacheE
* @author Alexander Capehart (OxygenCobalt) * @author Alexander Capehart (OxygenCobalt)
*/ */
@RequiresApi(Build.VERSION_CODES.Q) @RequiresApi(Build.VERSION_CODES.Q)
open class Api29MediaStoreExtractor(context: Context, cacheExtractor: CacheExtractor) : private open class Api29MediaStoreExtractor(context: Context, cacheExtractor: CacheExtractor) :
BaseApi29MediaStoreExtractor(context, cacheExtractor) { BaseApi29MediaStoreExtractor(context, cacheExtractor) {
private var trackIndex = -1 private var trackIndex = -1
override fun init(): Cursor { override suspend fun init(): Cursor {
val cursor = super.init() val cursor = super.init()
// Set up cursor indices for later use. // Set up cursor indices for later use.
trackIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.TRACK) trackIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.TRACK)
@ -536,12 +551,12 @@ open class Api29MediaStoreExtractor(context: Context, cacheExtractor: CacheExtra
* @author Alexander Capehart (OxygenCobalt) * @author Alexander Capehart (OxygenCobalt)
*/ */
@RequiresApi(Build.VERSION_CODES.R) @RequiresApi(Build.VERSION_CODES.R)
class Api30MediaStoreExtractor(context: Context, cacheExtractor: CacheExtractor) : private class Api30MediaStoreExtractor(context: Context, cacheExtractor: CacheExtractor) :
BaseApi29MediaStoreExtractor(context, cacheExtractor) { BaseApi29MediaStoreExtractor(context, cacheExtractor) {
private var trackIndex: Int = -1 private var trackIndex: Int = -1
private var discIndex: Int = -1 private var discIndex: Int = -1
override fun init(): Cursor { override suspend fun init(): Cursor {
val cursor = super.init() val cursor = super.init()
// Set up cursor indices for later use. // Set up cursor indices for later use.
trackIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.CD_TRACK_NUMBER) 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 com.google.android.exoplayer2.MetadataRetriever
import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flow
import org.oxycblt.auxio.music.Song 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.parseId3v2PositionField
import org.oxycblt.auxio.music.parsing.parseVorbisPositionField import org.oxycblt.auxio.music.parsing.parseVorbisPositionField
import org.oxycblt.auxio.music.storage.toAudioUri 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.logD
import org.oxycblt.auxio.util.logW import org.oxycblt.auxio.util.logW
@ -53,14 +54,14 @@ class MetadataExtractor(
* relies on. * relies on.
* @return The amount of music that is expected to be loaded. * @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 * Finalize the Extractor by writing the newly-loaded [Song.Raw]s back into the cache, alongside
* freeing up memory. * freeing up memory.
* @param rawSongs The songs to write into the cache. * @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 * 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/>. * 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.content.Context
import android.media.MediaExtractor import android.media.MediaExtractor

View file

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

View file

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

View file

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

View file

@ -15,7 +15,7 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
package org.oxycblt.auxio.music.extractor package org.oxycblt.auxio.music.format
import com.google.android.exoplayer2.metadata.Metadata import com.google.android.exoplayer2.metadata.Metadata
import com.google.android.exoplayer2.metadata.id3.InternalFrame 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.IntegerTable
import org.oxycblt.auxio.R import org.oxycblt.auxio.R
import org.oxycblt.auxio.music.* 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.library.Sort.Mode
import org.oxycblt.auxio.music.tags.Date
import org.oxycblt.auxio.music.tags.Disc
/** /**
* A sorting method. * A sorting method.

View file

@ -24,6 +24,7 @@ import android.os.Build
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import kotlinx.coroutines.yield import kotlinx.coroutines.yield
import org.oxycblt.auxio.BuildConfig 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 // Create the chain of extractors. Each extractor builds on the previous and
// enables version-specific features in order to create the best possible music // enables version-specific features in order to create the best possible music
// experience. // experience.
val cacheDatabase = val cacheExtractor = CacheExtractor.from(context, withCache)
if (withCache) { val mediaStoreExtractor = MediaStoreExtractor.from(context, cacheExtractor)
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 metadataExtractor = MetadataExtractor(context, mediaStoreExtractor) val metadataExtractor = MetadataExtractor(context, mediaStoreExtractor)
val rawSongs = loadRawSongs(metadataExtractor).ifEmpty { throw NoMusicException() } val rawSongs = loadRawSongs(metadataExtractor).ifEmpty { throw NoMusicException() }
// Build the rest of the music library from the song list. This is much more powerful // 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 androidx.room.TypeConverter
import org.oxycblt.auxio.music.Music import org.oxycblt.auxio.music.Music
import org.oxycblt.auxio.playback.state.RepeatMode
/** /**
* Defines conversions used in the persistence table. * Defines conversions used in the persistence table.

View file

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

View file

@ -28,7 +28,7 @@ import com.google.android.exoplayer2.util.MimeTypes
import java.nio.ByteBuffer import java.nio.ByteBuffer
import kotlin.math.pow import kotlin.math.pow
import org.oxycblt.auxio.music.Album 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.PlaybackSettings
import org.oxycblt.auxio.playback.state.PlaybackStateManager import org.oxycblt.auxio.playback.state.PlaybackStateManager
import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.util.logD

View file

@ -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.assertEquals
import org.junit.Assert.assertTrue import org.junit.Assert.assertTrue
import org.junit.Test import org.junit.Test
import org.oxycblt.auxio.music.tags.Date import org.oxycblt.auxio.music.format.Date
class MusicTest { class MusicTest {
@Test @Test

View file

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

View file

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

View file

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

View file

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