music: add caching

Add caching of already-parsed tag data.

This greatly reduces loading times when the music library has not
changed. This completes the music loader in it's entirety now.

Resolves #207.
This commit is contained in:
Alexander Capehart 2022-09-25 19:58:38 -06:00
parent 393bdf3110
commit 3e73cd8080
No known key found for this signature in database
GPG key ID: 37DBE3621FE9AD47
9 changed files with 443 additions and 97 deletions

View file

@ -10,8 +10,9 @@
- Artists and album artists are now both given UI entires - Artists and album artists are now both given UI entires
- Upgraded music ID management: - Upgraded music ID management:
- Use MD5 for default UUIDS - Use MD5 for default UUIDS
- Added support for MusicBrainz IDs (MBIDs) - Added support for MusicBrainz IDs (MBIDs
- Added toggle to load non-music (Such as podcasts) - Added toggle to load non-music (Such as podcasts)
- Music loader now caches parsed metadata for faster load times
#### What's Improved #### What's Improved
- Sorting now takes accented characters into account - Sorting now takes accented characters into account

View file

@ -3,6 +3,7 @@ plugins {
id "kotlin-android" id "kotlin-android"
id "androidx.navigation.safeargs.kotlin" id "androidx.navigation.safeargs.kotlin"
id "com.diffplug.spotless" id "com.diffplug.spotless"
id "kotlin-kapt"
id "kotlin-parcelize" id "kotlin-parcelize"
} }
@ -95,6 +96,12 @@ dependencies {
// Preferences // Preferences
implementation "androidx.preference:preference-ktx:1.2.0" implementation "androidx.preference:preference-ktx:1.2.0"
// Room
def room_version = "2.4.3"
implementation "androidx.room:room-runtime:$room_version"
kapt "androidx.room:room-compiler:$room_version"
implementation "androidx.room:room-ktx:$room_version"
// --- THIRD PARTY --- // --- THIRD PARTY ---
// Exoplayer // Exoplayer

View file

@ -358,7 +358,7 @@ class Song constructor(raw: Raw, settings: Settings) : Music() {
musicBrainzId = raw.albumMusicBrainzId?.toUuidOrNull(), musicBrainzId = raw.albumMusicBrainzId?.toUuidOrNull(),
name = requireNotNull(raw.albumName) { "Invalid raw: No album name" }, name = requireNotNull(raw.albumName) { "Invalid raw: No album name" },
sortName = raw.albumSortName, sortName = raw.albumSortName,
releaseType = raw.albumReleaseType.parseReleaseType(settings), releaseType = raw.albumReleaseTypes.parseReleaseType(settings),
rawArtists = rawAlbumArtists.ifEmpty { rawArtists }.ifEmpty { listOf(Artist.Raw(null, null)) } rawArtists = rawAlbumArtists.ifEmpty { rawArtists }.ifEmpty { listOf(Artist.Raw(null, null)) }
) )
@ -385,17 +385,17 @@ class Song constructor(raw: Raw, settings: Settings) : Music() {
class Raw class Raw
constructor( constructor(
var mediaStoreId: Long? = null, var mediaStoreId: Long? = null,
var musicBrainzId: String? = null,
var name: String? = null,
var fileName: String? = null,
var sortName: String? = null,
var directory: Directory? = null,
var extensionMimeType: String? = null,
var formatMimeType: String? = null,
var size: Long? = null,
var dateAdded: Long? = null, var dateAdded: Long? = null,
var dateModified: Long? = null, var dateModified: Long? = null,
var fileName: String? = null,
var directory: Directory? = null,
var size: Long? = null,
var durationMs: Long? = null, var durationMs: Long? = null,
var extensionMimeType: String? = null,
var formatMimeType: String? = null,
var musicBrainzId: String? = null,
var name: String? = null,
var sortName: String? = null,
var track: Int? = null, var track: Int? = null,
var disc: Int? = null, var disc: Int? = null,
var date: Date? = null, var date: Date? = null,
@ -403,7 +403,7 @@ class Song constructor(raw: Raw, settings: Settings) : Music() {
var albumMusicBrainzId: String? = null, var albumMusicBrainzId: String? = null,
var albumName: String? = null, var albumName: String? = null,
var albumSortName: String? = null, var albumSortName: String? = null,
var albumReleaseType: List<String> = listOf(), var albumReleaseTypes: List<String> = listOf(),
var artistMusicBrainzIds: List<String> = listOf(), var artistMusicBrainzIds: List<String> = listOf(),
var artistNames: List<String> = listOf(), var artistNames: List<String> = listOf(),
var artistSortNames: List<String> = listOf(), var artistSortNames: List<String> = listOf(),

View file

@ -1,41 +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.music.extractor
import org.oxycblt.auxio.music.Song
/** TODO: Stub class, not implemented yet */
class CacheDatabase {
fun init() {
}
// FIXME: Make the raw datatype use raw values, with most parsing being done in the song
// constructor to ensure cache coherency
/**
* Write a list of newly-indexed raw songs to the database.
*/
fun finalize(rawSongs: List<Song.Raw>) {
}
/**
* Maybe copy a cached raw song into this instance, assuming that it has not changed
* since it was last saved.
*/
fun maybePopulateCachedRaw(raw: Song.Raw) = false
}

View file

@ -0,0 +1,368 @@
/*
* Copyright (c) 2022 Auxio Project
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.oxycblt.auxio.music.extractor
import android.content.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.core.database.sqlite.transaction
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.util.logD
import org.oxycblt.auxio.util.queryAll
import org.oxycblt.auxio.util.requireBackgroundThread
import java.io.File
/**
* The extractor that caches music metadata for faster use later. The cache is only responsible for
* storing "intrinsic" data, as in information derived from the file format and not
* information from the media database or file system. The exceptions are the database ID and
* modification times for files, as these are required for the cache to function well.
* @author OxygenCobalt
*/
class CacheExtractor(private val context: Context) {
private var cacheMap: Map<Long, Song.Raw>? = null
private var shouldWriteCache = false
fun init() {
cacheMap = CacheDatabase.getInstance(context).read()
}
/**
* Write a list of newly-indexed raw songs to the database.
*/
fun finalize(rawSongs: List<Song.Raw>) {
cacheMap = null
if (shouldWriteCache) {
// If the entire library could not be loaded from the cache, we need to re-write it
// with the new library.
logD("Cache was invalidated during loading, rewriting")
CacheDatabase.getInstance(context).write(rawSongs)
}
}
/**
* Maybe copy a cached raw song into this instance, assuming that it has not changed
* since it was last saved.
*/
fun populateFromCache(rawSong: Song.Raw): Boolean {
val map = requireNotNull(cacheMap) { "CacheExtractor was not properly initialized" }
val cachedRawSong = map[rawSong.mediaStoreId]
if (cachedRawSong != null && cachedRawSong.dateAdded == rawSong.dateAdded && cachedRawSong.dateModified == rawSong.dateModified) {
rawSong.musicBrainzId = cachedRawSong.musicBrainzId
rawSong.name = cachedRawSong.name
rawSong.sortName = cachedRawSong.sortName
rawSong.size = cachedRawSong.size
rawSong.durationMs = cachedRawSong.durationMs
rawSong.formatMimeType = cachedRawSong.formatMimeType
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.albumReleaseTypes = cachedRawSong.albumReleaseTypes
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 true
}
shouldWriteCache = true
return false
}
}
private class CacheDatabase(context: Context) : SQLiteOpenHelper(context, File(context.cacheDir, DB_NAME).absolutePath, null, DB_VERSION) {
override fun onCreate(db: SQLiteDatabase) {
val command = StringBuilder()
.append("CREATE TABLE IF NOT EXISTS $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.FORMAT_MIME_TYPE} STRING,")
.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.DATE} STRING,")
.append("${Columns.ALBUM_MUSIC_BRAINZ_ID} STRING,")
.append("${Columns.ALBUM_NAME} STRING NOT NULL,")
.append("${Columns.ALBUM_SORT_NAME} STRING,")
.append("${Columns.ALBUM_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)")
db.execSQL(command.toString())
}
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) {
logD("Nuking database")
db.apply {
execSQL("DROP TABLE IF EXISTS $TABLE_RAW_SONGS")
onCreate(this)
}
}
fun read(): Map<Long, Song.Raw> {
requireBackgroundThread()
val map = mutableMapOf<Long, Song.Raw>()
readableDatabase.queryAll(TABLE_RAW_SONGS) { cursor ->
if (cursor.count == 0) 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 formatMimeTypeIndex = cursor.getColumnIndexOrThrow(Columns.FORMAT_MIME_TYPE)
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 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 albumReleaseTypesIndex = cursor.getColumnIndexOrThrow(Columns.ALBUM_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.formatMimeType = cursor.getStringOrNull(formatMimeTypeIndex)
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.date = cursor.getStringOrNull(dateIndex)?.parseTimestamp()
raw.albumMusicBrainzId = cursor.getStringOrNull(albumMusicBrainzIdIndex)
raw.albumName = cursor.getString(albumNameIndex)
raw.albumSortName = cursor.getStringOrNull(albumSortNameIndex)
cursor.getStringOrNull(albumReleaseTypesIndex)?.parseMultiValue()
?.let { raw.albumReleaseTypes = it }
cursor.getStringOrNull(artistMusicBrainzIdsIndex)?.let {
raw.artistMusicBrainzIds = it.parseMultiValue()
}
cursor.getStringOrNull(artistNamesIndex)
?.let { raw.artistNames = it.parseMultiValue() }
cursor.getStringOrNull(artistSortNamesIndex)
?.let { raw.artistSortNames = it.parseMultiValue() }
cursor.getStringOrNull(albumArtistMusicBrainzIdsIndex)
?.let { raw.albumArtistMusicBrainzIds = it.parseMultiValue() }
cursor.getStringOrNull(albumArtistNamesIndex)
?.let { raw.albumArtistNames = it.parseMultiValue() }
cursor.getStringOrNull(albumArtistSortNamesIndex)
?.let { raw.albumArtistSortNames = it.parseMultiValue() }
cursor.getStringOrNull(genresIndex)
?.let { raw.genreNames = it.parseMultiValue() }
map[id] = raw
}
}
return map
}
fun write(rawSongs: List<Song.Raw>) {
var position = 0
val database = writableDatabase
database.transaction { delete(TABLE_RAW_SONGS, null, null) }
logD("Cleared raw songs database")
while (position < rawSongs.size) {
var i = position
database.transaction {
while (i < rawSongs.size) {
val rawSong = rawSongs[i]
i++
val itemData =
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.FORMAT_MIME_TYPE, rawSong.formatMimeType)
put(Columns.MUSIC_BRAINZ_ID, rawSong.name)
put(Columns.NAME, rawSong.name)
put(Columns.SORT_NAME, rawSong.sortName)
put(Columns.TRACK, rawSong.track)
put(Columns.DISC, rawSong.disc)
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.ALBUM_RELEASE_TYPES, rawSong.albumReleaseTypes.toMultiValue())
put(Columns.ARTIST_MUSIC_BRAINZ_IDS, rawSong.artistMusicBrainzIds.toMultiValue())
put(Columns.ARTIST_NAMES, rawSong.artistNames.toMultiValue())
put(Columns.ARTIST_SORT_NAMES, rawSong.artistSortNames.toMultiValue())
put(Columns.ALBUM_ARTIST_MUSIC_BRAINZ_IDS, rawSong.albumArtistMusicBrainzIds.toMultiValue())
put(Columns.ALBUM_ARTIST_NAMES, rawSong.albumArtistNames.toMultiValue())
put(Columns.ALBUM_ARTIST_SORT_NAMES, rawSong.albumArtistSortNames.toMultiValue())
put(Columns.GENRE_NAMES, rawSong.genreNames.toMultiValue())
}
insert(TABLE_RAW_SONGS, null, itemData)
}
}
// Update the position at the end, if an insert failed at any point, then
// the next iteration should skip it.
position = i
logD("Wrote batch of raw songs. Position is now at $position")
}
}
// SQLite does not natively support multiple values, so we have to serialize multi-value
// tags with separators. Not ideal, but nothing we can do.
private fun List<String>.toMultiValue() =
if (isNotEmpty()) {
joinToString(";") { it.replace(";", "\\;") }
} else {
null
}
private fun String.parseMultiValue() = splitEscaped { it == ';' }
private object Columns {
const val MEDIA_STORE_ID = "msid"
const val DATE_ADDED = "date_added"
const val DATE_MODIFIED = "date_modified"
const val SIZE = "size"
const val DURATION = "duration"
const val FORMAT_MIME_TYPE = "fmt_mime"
const val MUSIC_BRAINZ_ID = "mbid"
const val NAME = "name"
const val SORT_NAME = "sort_name"
const val TRACK = "track"
const val DISC = "disc"
const val DATE = "date"
const val ALBUM_MUSIC_BRAINZ_ID = "album_mbid"
const val ALBUM_NAME = "album"
const val ALBUM_SORT_NAME = "album_sort"
const val ALBUM_RELEASE_TYPES = "album_types"
const val ARTIST_MUSIC_BRAINZ_IDS = "artists_mbid"
const val ARTIST_NAMES = "artists"
const val ARTIST_SORT_NAMES = "artists_sort"
const val ALBUM_ARTIST_MUSIC_BRAINZ_IDS = "album_artists_mbid"
const val ALBUM_ARTIST_NAMES = "album_artists"
const val ALBUM_ARTIST_SORT_NAMES = "album_artists_sort"
const val GENRE_NAMES = "genres"
}
companion object {
const val DB_NAME = "auxio_music_cache.db"
const val DB_VERSION = 1
const val TABLE_RAW_SONGS = "raw_songs"
@Volatile private var INSTANCE: CacheDatabase? = null
/** Get/Instantiate the single instance of [CacheDatabase]. */
fun getInstance(context: Context): CacheDatabase {
val currentInstance = INSTANCE
if (currentInstance != null) {
return currentInstance
}
synchronized(this) {
val newInstance = CacheDatabase(context.applicationContext)
INSTANCE = newInstance
return newInstance
}
}
}
}

View file

@ -100,7 +100,7 @@ import java.io.File
* music loading process. * music loading process.
* @author OxygenCobalt * @author OxygenCobalt
*/ */
abstract class MediaStoreExtractor(private val context: Context, private val cacheDatabase: CacheDatabase) { abstract class MediaStoreExtractor(private val context: Context, private val cacheDatabase: CacheExtractor) {
private var cursor: Cursor? = null private var cursor: Cursor? = null
private var idIndex = -1 private var idIndex = -1
@ -259,17 +259,14 @@ abstract class MediaStoreExtractor(private val context: Context, private val cac
} }
// Populate the minimum required fields to maybe obtain a cache entry. // Populate the minimum required fields to maybe obtain a cache entry.
raw.mediaStoreId = cursor.getLong(idIndex) populateFileData(cursor, raw)
raw.dateAdded = cursor.getLong(dateAddedIndex)
raw.dateModified = cursor.getLong(dateAddedIndex)
if (cacheDatabase.maybePopulateCachedRaw(raw)) { if (cacheDatabase.populateFromCache(raw)) {
// We found a valid cache entry, no need to extract metadata. // We found a valid cache entry, no need to extract metadata.
logD("Found cached raw: ${raw.name}")
return true return true
} }
buildRaw(cursor, raw) populateMetadata(cursor, raw)
// We had to freshly make this raw, return false // We had to freshly make this raw, return false
return false return false
@ -279,7 +276,7 @@ abstract class MediaStoreExtractor(private val context: Context, private val cac
* The projection to use when querying media. Add version-specific columns here in an * The projection to use when querying media. Add version-specific columns here in an
* implementation. * implementation.
*/ */
open val projection: Array<String> protected open val projection: Array<String>
get() = get() =
arrayOf( arrayOf(
// These columns are guaranteed to work on all versions of android // These columns are guaranteed to work on all versions of android
@ -298,25 +295,35 @@ abstract class MediaStoreExtractor(private val context: Context, private val cac
AUDIO_COLUMN_ALBUM_ARTIST AUDIO_COLUMN_ALBUM_ARTIST
) )
abstract val dirSelector: String protected abstract val dirSelector: String
abstract fun addDirToSelectorArgs(dir: Directory, args: MutableList<String>): Boolean protected abstract fun addDirToSelectorArgs(dir: Directory, args: MutableList<String>): Boolean
/** /**
* Build an [Song.Raw] based on the current cursor values. Each implementation should try to * Populate the "file data" of the cursor, or data that is required to access a cache entry
* obtain an upstream [Song.Raw] first, and then populate it with version-specific fields * or makes no sense to cache. This includes database IDs, modification dates,
* outlined in [projection].
*/ */
protected open fun buildRaw(cursor: Cursor, raw: Song.Raw) { protected open fun populateFileData(cursor: Cursor, raw: Song.Raw) {
raw.name = cursor.getString(titleIndex) raw.mediaStoreId = cursor.getLong(idIndex)
raw.dateAdded = cursor.getLong(dateAddedIndex)
raw.extensionMimeType = cursor.getString(mimeTypeIndex) raw.dateModified = cursor.getLong(dateAddedIndex)
raw.size = cursor.getLong(sizeIndex)
// Try to use the DISPLAY_NAME field to obtain a (probably sane) file name // Try to use the DISPLAY_NAME field to obtain a (probably sane) file name
// from the android system. // from the android system.
raw.fileName = cursor.getStringOrNull(displayNameIndex) raw.fileName = cursor.getStringOrNull(displayNameIndex)
raw.extensionMimeType = cursor.getString(mimeTypeIndex)
raw.albumMediaStoreId = cursor.getLong(albumIdIndex)
}
/**
* Extract cursor metadata into [raw].
*/
protected open fun populateMetadata(cursor: Cursor, raw: Song.Raw) {
raw.name = cursor.getString(titleIndex)
raw.size = cursor.getLong(sizeIndex)
raw.durationMs = cursor.getLong(durationIndex) raw.durationMs = cursor.getLong(durationIndex)
raw.date = cursor.getIntOrNull(yearIndex)?.toDate() raw.date = cursor.getIntOrNull(yearIndex)?.toDate()
// A non-existent album name should theoretically be the name of the folder it contained // A non-existent album name should theoretically be the name of the folder it contained
@ -324,7 +331,6 @@ abstract class MediaStoreExtractor(private val context: Context, private val cac
// file is not actually in the root internal storage directory. We can't do anything to // file is not actually in the root internal storage directory. We can't do anything to
// fix this, really. // fix this, really.
raw.albumName = cursor.getString(albumIndex) raw.albumName = cursor.getString(albumIndex)
raw.albumMediaStoreId = cursor.getLong(albumIdIndex)
// Android does not make a non-existent artist tag null, it instead fills it in // Android does not make a non-existent artist tag null, it instead fills it in
// as <unknown>, which makes absolutely no sense given how other fields default // as <unknown>, which makes absolutely no sense given how other fields default
@ -375,7 +381,7 @@ abstract class MediaStoreExtractor(private val context: Context, private val cac
* API 21 onwards to API 29. * API 21 onwards to API 29.
* @author OxygenCobalt * @author OxygenCobalt
*/ */
class Api21MediaStoreExtractor(context: Context, cacheDatabase: CacheDatabase) : class Api21MediaStoreExtractor(context: Context, cacheDatabase: CacheExtractor) :
MediaStoreExtractor(context, cacheDatabase) { MediaStoreExtractor(context, cacheDatabase) {
private var trackIndex = -1 private var trackIndex = -1
private var dataIndex = -1 private var dataIndex = -1
@ -401,8 +407,8 @@ class Api21MediaStoreExtractor(context: Context, cacheDatabase: CacheDatabase) :
return true return true
} }
override fun buildRaw(cursor: Cursor, raw: Song.Raw) { override fun populateFileData(cursor: Cursor, raw: Song.Raw) {
super.buildRaw(cursor, raw) super.populateFileData(cursor, raw)
// DATA is equivalent to the absolute path of the file. // DATA is equivalent to the absolute path of the file.
val data = cursor.getString(dataIndex) val data = cursor.getString(dataIndex)
@ -426,6 +432,10 @@ class Api21MediaStoreExtractor(context: Context, cacheDatabase: CacheDatabase) :
break break
} }
} }
}
override fun populateMetadata(cursor: Cursor, raw: Song.Raw) {
super.populateMetadata(cursor, raw)
val rawTrack = cursor.getIntOrNull(trackIndex) val rawTrack = cursor.getIntOrNull(trackIndex)
if (rawTrack != null) { if (rawTrack != null) {
@ -441,7 +451,7 @@ class Api21MediaStoreExtractor(context: Context, cacheDatabase: CacheDatabase) :
* @author OxygenCobalt * @author OxygenCobalt
*/ */
@RequiresApi(Build.VERSION_CODES.Q) @RequiresApi(Build.VERSION_CODES.Q)
open class BaseApi29MediaStoreExtractor(context: Context, cacheDatabase: CacheDatabase) : open class BaseApi29MediaStoreExtractor(context: Context, cacheDatabase: CacheExtractor) :
MediaStoreExtractor(context, cacheDatabase) { MediaStoreExtractor(context, cacheDatabase) {
private var volumeIndex = -1 private var volumeIndex = -1
private var relativePathIndex = -1 private var relativePathIndex = -1
@ -476,8 +486,8 @@ open class BaseApi29MediaStoreExtractor(context: Context, cacheDatabase: CacheDa
return true return true
} }
override fun buildRaw(cursor: Cursor, raw: Song.Raw) { override fun populateFileData(cursor: Cursor, raw: Song.Raw) {
super.buildRaw(cursor, raw) super.populateFileData(cursor, raw)
val volumeName = cursor.getString(volumeIndex) val volumeName = cursor.getString(volumeIndex)
val relativePath = cursor.getString(relativePathIndex) val relativePath = cursor.getString(relativePathIndex)
@ -497,7 +507,7 @@ open class BaseApi29MediaStoreExtractor(context: Context, cacheDatabase: CacheDa
* @author OxygenCobalt * @author OxygenCobalt
*/ */
@RequiresApi(Build.VERSION_CODES.Q) @RequiresApi(Build.VERSION_CODES.Q)
open class Api29MediaStoreExtractor(context: Context, cacheDatabase: CacheDatabase) : open class Api29MediaStoreExtractor(context: Context, cacheDatabase: CacheExtractor) :
BaseApi29MediaStoreExtractor(context, cacheDatabase) { BaseApi29MediaStoreExtractor(context, cacheDatabase) {
private var trackIndex = -1 private var trackIndex = -1
@ -510,8 +520,8 @@ open class Api29MediaStoreExtractor(context: Context, cacheDatabase: CacheDataba
override val projection: Array<String> override val projection: Array<String>
get() = super.projection + arrayOf(MediaStore.Audio.AudioColumns.TRACK) get() = super.projection + arrayOf(MediaStore.Audio.AudioColumns.TRACK)
override fun buildRaw(cursor: Cursor, raw: Song.Raw) { override fun populateMetadata(cursor: Cursor, raw: Song.Raw) {
super.buildRaw(cursor, raw) super.populateMetadata(cursor, raw)
// This backend is volume-aware, but does not support the modern track fields. // This backend is volume-aware, but does not support the modern track fields.
// Use the old field instead. // Use the old field instead.
@ -529,7 +539,7 @@ open class Api29MediaStoreExtractor(context: Context, cacheDatabase: CacheDataba
* @author OxygenCobalt * @author OxygenCobalt
*/ */
@RequiresApi(Build.VERSION_CODES.R) @RequiresApi(Build.VERSION_CODES.R)
class Api30MediaStoreExtractor(context: Context, cacheDatabase: CacheDatabase) : class Api30MediaStoreExtractor(context: Context, cacheDatabase: CacheExtractor) :
BaseApi29MediaStoreExtractor(context, cacheDatabase) { BaseApi29MediaStoreExtractor(context, cacheDatabase) {
private var trackIndex: Int = -1 private var trackIndex: Int = -1
private var discIndex: Int = -1 private var discIndex: Int = -1
@ -549,8 +559,8 @@ class Api30MediaStoreExtractor(context: Context, cacheDatabase: CacheDatabase) :
MediaStore.Audio.AudioColumns.DISC_NUMBER MediaStore.Audio.AudioColumns.DISC_NUMBER
) )
override fun buildRaw(cursor: Cursor, raw: Song.Raw) { override fun populateMetadata(cursor: Cursor, raw: Song.Raw) {
super.buildRaw(cursor, raw) super.populateMetadata(cursor, raw)
// Both CD_TRACK_NUMBER and DISC_NUMBER tend to be formatted as they are in // Both CD_TRACK_NUMBER and DISC_NUMBER tend to be formatted as they are in
// the tag itself, which is to say that it is formatted as NN/TT tracks, where // the tag itself, which is to say that it is formatted as NN/TT tracks, where

View file

@ -227,12 +227,12 @@ class Task(context: Context, private val raw: Song.Raw) {
tags["TALB"]?.let { raw.albumName = it[0] } tags["TALB"]?.let { raw.albumName = it[0] }
tags["TSOA"]?.let { raw.albumSortName = it[0] } tags["TSOA"]?.let { raw.albumSortName = it[0] }
(tags["TXXX:MusicBrainz Album Type"] ?: tags["GRP1"])?.let { (tags["TXXX:MusicBrainz Album Type"] ?: tags["GRP1"])?.let {
raw.albumReleaseType = it raw.albumReleaseTypes = it
} }
// Artist // Artist
tags["TXXX:MusicBrainz Artist Id"]?.let { raw.artistMusicBrainzIds = it } tags["TXXX:MusicBrainz Artist Id"]?.let { raw.artistMusicBrainzIds = it }
(tags["TXXX:ARTISTS"] ?: tags["TPE1"])?.let { raw.artistNames = it } tags["TPE1"]?.let { raw.artistNames = it }
tags["TSOP"]?.let { raw.artistSortNames = it } tags["TSOP"]?.let { raw.artistSortNames = it }
// Album artist // Album artist
@ -299,17 +299,17 @@ class Task(context: Context, private val raw: Song.Raw) {
tags["MUSICBRAINZ_ALBUMID"]?.let { raw.albumMusicBrainzId = it[0] } tags["MUSICBRAINZ_ALBUMID"]?.let { raw.albumMusicBrainzId = it[0] }
tags["ALBUM"]?.let { raw.albumName = it[0] } tags["ALBUM"]?.let { raw.albumName = it[0] }
tags["ALBUMSORT"]?.let { raw.albumSortName = it[0] } tags["ALBUMSORT"]?.let { raw.albumSortName = it[0] }
tags["RELEASETYPE"]?.let { raw.albumReleaseType = it } tags["RELEASETYPE"]?.let { raw.albumReleaseTypes = it }
// Artist // Artist
tags["MUSICBRAINZ_ARTISTID"]?.let { raw.artistMusicBrainzIds = it } tags["MUSICBRAINZ_ARTISTID"]?.let { raw.artistMusicBrainzIds = it }
(tags["ARTISTS"] ?: tags["ARTIST"])?.let { raw.artistNames = it } tags["ARTIST"]?.let { raw.artistNames = it }
(tags["ARTISTS_SORT"] ?: tags["ARTISTSORT"])?.let { raw.artistSortNames = it } tags["ARTISTSORT"]?.let { raw.artistSortNames = it }
// Album artist // Album artist
tags["MUSICBRAINZ_ALBUMARTISTID"]?.let { raw.albumArtistMusicBrainzIds = it } tags["MUSICBRAINZ_ALBUMARTISTID"]?.let { raw.albumArtistMusicBrainzIds = it }
(tags["ALBUMARTISTS"] ?: tags["ALBUMARTIST"])?.let { raw.albumArtistNames = it } tags["ALBUMARTIST"]?.let { raw.albumArtistNames = it }
(tags["ALBUMARTISTS_SORT"] ?: tags["ALBUMARTISTSORT"])?.let { raw.albumArtistSortNames = it } tags["ALBUMARTISTSORT"]?.let { raw.albumArtistSortNames = it }
// Genre // Genre
tags["GENRE"]?.let { raw.genreNames = it } tags["GENRE"]?.let { raw.genreNames = it }

View file

@ -37,7 +37,7 @@ import org.oxycblt.auxio.music.Sort
import org.oxycblt.auxio.music.extractor.Api21MediaStoreExtractor import org.oxycblt.auxio.music.extractor.Api21MediaStoreExtractor
import org.oxycblt.auxio.music.extractor.Api29MediaStoreExtractor import org.oxycblt.auxio.music.extractor.Api29MediaStoreExtractor
import org.oxycblt.auxio.music.extractor.Api30MediaStoreExtractor import org.oxycblt.auxio.music.extractor.Api30MediaStoreExtractor
import org.oxycblt.auxio.music.extractor.CacheDatabase import org.oxycblt.auxio.music.extractor.CacheExtractor
import org.oxycblt.auxio.music.extractor.MetadataExtractor import org.oxycblt.auxio.music.extractor.MetadataExtractor
import org.oxycblt.auxio.settings.Settings import org.oxycblt.auxio.settings.Settings
import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.util.logD
@ -196,14 +196,12 @@ class Indexer {
* Run the proper music loading process. * Run the proper music loading process.
*/ */
private suspend fun indexImpl(context: Context): MusicStore.Library? { private suspend fun indexImpl(context: Context): MusicStore.Library? {
emitIndexing(Indexing.Indeterminate)
// 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. This is technically dependency injection. Except it doesn't increase // experience. This is technically dependency injection. Except it doesn't increase
// your compile times by 3x. Isn't that nice. // your compile times by 3x. Isn't that nice.
val cacheDatabase = CacheDatabase() val cacheDatabase = CacheExtractor(context)
val mediaStoreExtractor = val mediaStoreExtractor =
when { when {
@ -259,6 +257,7 @@ class Indexer {
logD("Starting indexing process") logD("Starting indexing process")
val start = System.currentTimeMillis() val start = System.currentTimeMillis()
emitIndexing(Indexing.Indeterminate)
// Initialize the extractor chain. This also nets us the projected total // Initialize the extractor chain. This also nets us the projected total
// that we can show when loading. // that we can show when loading.
@ -279,6 +278,8 @@ class Indexer {
emitIndexing(Indexing.Songs(songs.size, total)) emitIndexing(Indexing.Songs(songs.size, total))
} }
emitIndexing(Indexing.Indeterminate)
metadataExtractor.finalize(rawSongs) metadataExtractor.finalize(rawSongs)
logD("Successfully built ${songs.size} songs in ${System.currentTimeMillis() - start}ms") logD("Successfully built ${songs.size} songs in ${System.currentTimeMillis() - start}ms")

View file

@ -282,11 +282,11 @@ class PlaybackStateDatabase private constructor(context: Context) :
} }
companion object { companion object {
const val DB_NAME = "auxio_state_database.db" const val DB_NAME = "auxio_playback_state.db"
const val DB_VERSION = 8 const val DB_VERSION = 8
const val TABLE_STATE = "playback_state_table" const val TABLE_STATE = "playback_state"
const val TABLE_QUEUE = "queue_table" const val TABLE_QUEUE = "queue"
@Volatile private var INSTANCE: PlaybackStateDatabase? = null @Volatile private var INSTANCE: PlaybackStateDatabase? = null