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:
parent
f27215a4be
commit
d2f74fd138
23 changed files with 262 additions and 474 deletions
|
@ -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.*
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
|
@ -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.
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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() {
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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")
|
||||
}
|
||||
}
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
Loading…
Reference in a new issue