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.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.*
|
||||||
|
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
|
@ -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.
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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.
|
||||||
|
|
|
@ -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() {
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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.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
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
Loading…
Reference in a new issue