music: split off raw music from real music

Split off "raw" music (RawSong, RawAlbum) from real music impls
(RealSong, RealAlbum).

They don't really make sense as a sub-class anymore given that there is
no longer a canonical music datastructure.
This commit is contained in:
Alexander Capehart 2023-02-02 20:36:58 -07:00
parent 4afe91e4e8
commit 2a3e81889b
No known key found for this signature in database
GPG key ID: 37DBE3621FE9AD47
11 changed files with 463 additions and 477 deletions

View file

@ -30,6 +30,7 @@ import java.io.File
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.yield
import org.oxycblt.auxio.music.MusicSettings
import org.oxycblt.auxio.music.library.RawSong
import org.oxycblt.auxio.music.library.RealSong
import org.oxycblt.auxio.music.metadata.Date
import org.oxycblt.auxio.music.parsing.parseId3v2PositionField
@ -68,8 +69,8 @@ interface MediaStoreExtractor {
*/
suspend fun consume(
cache: MetadataCache?,
incompleteSongs: Channel<RealSong.Raw>,
completeSongs: Channel<RealSong.Raw>
incompleteSongs: Channel<RawSong>,
completeSongs: Channel<RawSong>
)
companion object {
@ -219,12 +220,12 @@ private abstract class RealMediaStoreExtractor(private val context: Context) : M
override suspend fun consume(
cache: MetadataCache?,
incompleteSongs: Channel<RealSong.Raw>,
completeSongs: Channel<RealSong.Raw>
incompleteSongs: Channel<RawSong>,
completeSongs: Channel<RawSong>
) {
val cursor = requireNotNull(cursor) { "Must call query first before running consume" }
while (cursor.moveToNext()) {
val rawSong = RealSong.Raw()
val rawSong = RawSong()
populateFileData(cursor, rawSong)
if (cache?.populate(rawSong) == true) {
completeSongs.send(rawSong)
@ -281,61 +282,61 @@ private abstract class RealMediaStoreExtractor(private val context: Context) : M
protected abstract fun addDirToSelector(dir: Directory, args: MutableList<String>): Boolean
/**
* Populate a [RealSong.Raw] with the "File Data" of the given [MediaStore] [Cursor], which is
* the data that cannot be cached. This includes any information not intrinsic to the file and
* instead dependent on the file-system, which could change without invalidating the cache due
* to volume additions or removals.
* Populate a [RawSong] with the "File Data" of the given [MediaStore] [Cursor], which
* is the data that cannot be cached. This includes any information not intrinsic to the file
* and instead dependent on the file-system, which could change without invalidating the cache
* due to volume additions or removals.
* @param cursor The [Cursor] to read from.
* @param raw The [RealSong.Raw] to populate.
* @param rawSong The [RawSong] to populate.
* @see populateMetadata
*/
protected open fun populateFileData(cursor: Cursor, raw: RealSong.Raw) {
raw.mediaStoreId = cursor.getLong(idIndex)
raw.dateAdded = cursor.getLong(dateAddedIndex)
raw.dateModified = cursor.getLong(dateAddedIndex)
protected open fun populateFileData(cursor: Cursor, rawSong: RawSong) {
rawSong.mediaStoreId = cursor.getLong(idIndex)
rawSong.dateAdded = cursor.getLong(dateAddedIndex)
rawSong.dateModified = cursor.getLong(dateAddedIndex)
// Try to use the DISPLAY_NAME column to obtain a (probably sane) file name
// from the android system.
raw.fileName = cursor.getStringOrNull(displayNameIndex)
raw.extensionMimeType = cursor.getString(mimeTypeIndex)
raw.albumMediaStoreId = cursor.getLong(albumIdIndex)
rawSong.fileName = cursor.getStringOrNull(displayNameIndex)
rawSong.extensionMimeType = cursor.getString(mimeTypeIndex)
rawSong.albumMediaStoreId = cursor.getLong(albumIdIndex)
}
/**
* Populate a [RealSong.Raw] with the Metadata of the given [MediaStore] [Cursor], which is the
* data about a [RealSong.Raw] that can be cached. This includes any information intrinsic to
* the file or it's file format, such as music tags.
* Populate a [RawSong] with the Metadata of the given [MediaStore] [Cursor], which is
* the data about a [RawSong] that can be cached. This includes any information
* intrinsic to the file or it's file format, such as music tags.
* @param cursor The [Cursor] to read from.
* @param raw The [RealSong.Raw] to populate.
* @param rawSong The [RawSong] to populate.
* @see populateFileData
*/
protected open fun populateMetadata(cursor: Cursor, raw: RealSong.Raw) {
protected open fun populateMetadata(cursor: Cursor, rawSong: RawSong) {
// Song title
raw.name = cursor.getString(titleIndex)
rawSong.name = cursor.getString(titleIndex)
// Size (in bytes)
raw.size = cursor.getLong(sizeIndex)
rawSong.size = cursor.getLong(sizeIndex)
// Duration (in milliseconds)
raw.durationMs = cursor.getLong(durationIndex)
rawSong.durationMs = cursor.getLong(durationIndex)
// MediaStore only exposes the year value of a file. This is actually worse than it
// seems, as it means that it will not read ID3v2 TDRC tags or Vorbis DATE comments.
// This is one of the major weaknesses of using MediaStore, hence the redundancy layers.
raw.date = cursor.getStringOrNull(yearIndex)?.let(Date::from)
rawSong.date = cursor.getStringOrNull(yearIndex)?.let(Date::from)
// A non-existent album name should theoretically be the name of the folder it contained
// in, but in practice it is more often "0" (as in /storage/emulated/0), even when it the
// file is not actually in the root internal storage directory. We can't do anything to
// fix this, really.
raw.albumName = cursor.getString(albumIndex)
rawSong.albumName = cursor.getString(albumIndex)
// 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 columns default
// to null if they are not present. If this column is such, null it so that
// it's easier to handle later.
val artist = cursor.getString(artistIndex)
if (artist != MediaStore.UNKNOWN_STRING) {
raw.artistNames = listOf(artist)
rawSong.artistNames = listOf(artist)
}
// The album artist column is nullable and never has placeholder values.
cursor.getStringOrNull(albumArtistIndex)?.let { raw.albumArtistNames = listOf(it) }
cursor.getStringOrNull(albumArtistIndex)?.let { rawSong.albumArtistNames = listOf(it) }
// Get the genre value we had to query for in initialization
genreNamesMap[raw.mediaStoreId]?.let { raw.genreNames = listOf(it) }
genreNamesMap[rawSong.mediaStoreId]?.let { rawSong.genreNames = listOf(it) }
}
companion object {
@ -398,8 +399,8 @@ private class Api21MediaStoreExtractor(context: Context) : RealMediaStoreExtract
return true
}
override fun populateFileData(cursor: Cursor, raw: RealSong.Raw) {
super.populateFileData(cursor, raw)
override fun populateFileData(cursor: Cursor, rawSong: RawSong) {
super.populateFileData(cursor, rawSong)
val data = cursor.getString(dataIndex)
@ -407,8 +408,8 @@ private class Api21MediaStoreExtractor(context: Context) : RealMediaStoreExtract
// that this only applies to below API 29, as beyond API 29, this column not being
// present would completely break the scoped storage system. Fill it in with DATA
// if it's not available.
if (raw.fileName == null) {
raw.fileName = data.substringAfterLast(File.separatorChar, "").ifEmpty { null }
if (rawSong.fileName == null) {
rawSong.fileName = data.substringAfterLast(File.separatorChar, "").ifEmpty { null }
}
// Find the volume that transforms the DATA column into a relative path. This is
@ -418,20 +419,20 @@ private class Api21MediaStoreExtractor(context: Context) : RealMediaStoreExtract
val volumePath = volume.directoryCompat ?: continue
val strippedPath = rawPath.removePrefix(volumePath)
if (strippedPath != rawPath) {
raw.directory = Directory.from(volume, strippedPath)
rawSong.directory = Directory.from(volume, strippedPath)
break
}
}
}
override fun populateMetadata(cursor: Cursor, raw: RealSong.Raw) {
super.populateMetadata(cursor, raw)
override fun populateMetadata(cursor: Cursor, rawSong: RawSong) {
super.populateMetadata(cursor, rawSong)
// See unpackTrackNo/unpackDiscNo for an explanation
// of how this column is set up.
val rawTrack = cursor.getIntOrNull(trackIndex)
if (rawTrack != null) {
rawTrack.unpackTrackNo()?.let { raw.track = it }
rawTrack.unpackDiscNo()?.let { raw.disc = it }
rawTrack.unpackTrackNo()?.let { rawSong.track = it }
rawTrack.unpackDiscNo()?.let { rawSong.disc = it }
}
}
}
@ -439,7 +440,6 @@ private class Api21MediaStoreExtractor(context: Context) : RealMediaStoreExtract
/**
* A [RealMediaStoreExtractor] that implements common behavior supported from API 29 onwards.
* @param context [Context] required to query the media database.
* @param metadataCacheRepository [MetadataCacheRepository] implementation for cache optimizations.
* @author Alexander Capehart (OxygenCobalt)
*/
@RequiresApi(Build.VERSION_CODES.Q)
@ -485,15 +485,15 @@ private open class BaseApi29MediaStoreExtractor(context: Context) :
return true
}
override fun populateFileData(cursor: Cursor, raw: RealSong.Raw) {
super.populateFileData(cursor, raw)
override fun populateFileData(cursor: Cursor, rawSong: RawSong) {
super.populateFileData(cursor, rawSong)
// Find the StorageVolume whose MediaStore name corresponds to this song.
// This is combined with the plain relative path column to create the directory.
val volumeName = cursor.getString(volumeIndex)
val relativePath = cursor.getString(relativePathIndex)
val volume = volumes.find { it.mediaStoreVolumeNameCompat == volumeName }
if (volume != null) {
raw.directory = Directory.from(volume, relativePath)
rawSong.directory = Directory.from(volume, relativePath)
}
}
}
@ -503,7 +503,6 @@ private open class BaseApi29MediaStoreExtractor(context: Context) :
* API
* 29.
* @param context [Context] required to query the media database.
* @param metadataCacheRepository [MetadataCacheRepository] implementation for cache functionality.
* @author Alexander Capehart (OxygenCobalt)
*/
@RequiresApi(Build.VERSION_CODES.Q)
@ -521,15 +520,15 @@ private open class Api29MediaStoreExtractor(context: Context) :
override val projection: Array<String>
get() = super.projection + arrayOf(MediaStore.Audio.AudioColumns.TRACK)
override fun populateMetadata(cursor: Cursor, raw: RealSong.Raw) {
super.populateMetadata(cursor, raw)
override fun populateMetadata(cursor: Cursor, rawSong: RawSong) {
super.populateMetadata(cursor, rawSong)
// This extractor is volume-aware, but does not support the modern track columns.
// Use the old column instead. See unpackTrackNo/unpackDiscNo for an explanation
// of how this column is set up.
val rawTrack = cursor.getIntOrNull(trackIndex)
if (rawTrack != null) {
rawTrack.unpackTrackNo()?.let { raw.track = it }
rawTrack.unpackDiscNo()?.let { raw.disc = it }
rawTrack.unpackTrackNo()?.let { rawSong.track = it }
rawTrack.unpackDiscNo()?.let { rawSong.disc = it }
}
}
}
@ -538,7 +537,6 @@ private open class Api29MediaStoreExtractor(context: Context) :
* A [RealMediaStoreExtractor] that completes the music loading process in a way compatible from API
* 30 onwards.
* @param context [Context] required to query the media database.
* @param metadataCacheRepository [MetadataCacheRepository] implementation for cache optimizations.
* @author Alexander Capehart (OxygenCobalt)
*/
@RequiresApi(Build.VERSION_CODES.R)
@ -563,14 +561,14 @@ private class Api30MediaStoreExtractor(context: Context) : BaseApi29MediaStoreEx
MediaStore.Audio.AudioColumns.CD_TRACK_NUMBER,
MediaStore.Audio.AudioColumns.DISC_NUMBER)
override fun populateMetadata(cursor: Cursor, raw: RealSong.Raw) {
super.populateMetadata(cursor, raw)
override fun populateMetadata(cursor: Cursor, rawSong: RawSong) {
super.populateMetadata(cursor, rawSong)
// 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
// N is the number and T is the total. Parse the number while ignoring the
// total, as we have no use for it.
cursor.getStringOrNull(trackIndex)?.parseId3v2PositionField()?.let { raw.track = it }
cursor.getStringOrNull(discIndex)?.parseId3v2PositionField()?.let { raw.disc = it }
cursor.getStringOrNull(trackIndex)?.parseId3v2PositionField()?.let { rawSong.track = it }
cursor.getStringOrNull(discIndex)?.parseId3v2PositionField()?.let { rawSong.disc = it }
}
}

View file

@ -29,6 +29,7 @@ import androidx.room.RoomDatabase
import androidx.room.TypeConverter
import androidx.room.TypeConverters
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.music.library.RawSong
import org.oxycblt.auxio.music.library.RealSong
import org.oxycblt.auxio.music.metadata.Date
import org.oxycblt.auxio.music.parsing.correctWhitespace
@ -41,15 +42,15 @@ import org.oxycblt.auxio.util.*
* @author Alexander Capehart (OxygenCobalt)
*/
interface MetadataCache {
/** Whether this cache has encountered a [RealSong.Raw] that did not have a cache entry. */
/** Whether this cache has encountered a [RawSong] that did not have a cache entry. */
val invalidated: Boolean
/**
* Populate a [RealSong.Raw] from a cache entry, if it exists.
* @param rawSong The [RealSong.Raw] to populate.
* Populate a [RawSong] from a cache entry, if it exists.
* @param rawSong The [RawSong] to populate.
* @return true if a cache entry could be applied to [rawSong], false otherwise.
*/
fun populate(rawSong: RealSong.Raw): Boolean
fun populate(rawSong: RawSong): Boolean
}
private class RealMetadataCache(cachedSongs: List<CachedSong>) : MetadataCache {
@ -60,7 +61,7 @@ private class RealMetadataCache(cachedSongs: List<CachedSong>) : MetadataCache {
}
override var invalidated = false
override fun populate(rawSong: RealSong.Raw): Boolean {
override fun populate(rawSong: RawSong): Boolean {
// For a cached raw song to be used, it must exist within the cache and have matching
// addition and modification timestamps. Technically the addition timestamp doesn't
@ -93,10 +94,10 @@ interface MetadataCacheRepository {
suspend fun readCache(): MetadataCache?
/**
* Write the list of newly-loaded [RealSong.Raw]s to the cache, replacing the prior data.
* Write the list of newly-loaded [RawSong]s to the cache, replacing the prior data.
* @param rawSongs The [rawSongs] to write to the cache.
*/
suspend fun writeCache(rawSongs: List<RealSong.Raw>)
suspend fun writeCache(rawSongs: List<RawSong>)
companion object {
/**
@ -124,7 +125,7 @@ private class RealMetadataCacheRepository(private val context: Context) : Metada
null
}
override suspend fun writeCache(rawSongs: List<RealSong.Raw>) {
override suspend fun writeCache(rawSongs: List<RawSong>) {
try {
// Still write out whatever data was extracted.
cachedSongsDao.nukeSongs()
@ -185,52 +186,52 @@ private data class CachedSong(
* unstable and should only be used for accessing the audio file.
*/
@PrimaryKey var mediaStoreId: Long,
/** @see RealSong.Raw.dateAdded */
/** @see RawSong.dateAdded */
var dateAdded: Long,
/** The latest date the [Song]'s audio file was modified, as a unix epoch timestamp. */
var dateModified: Long,
/** @see RealSong.Raw.size */
/** @see RawSong.size */
var size: Long? = null,
/** @see RealSong.Raw */
/** @see RawSong */
var durationMs: Long,
/** @see RealSong.Raw.musicBrainzId */
/** @see RawSong.musicBrainzId */
var musicBrainzId: String? = null,
/** @see RealSong.Raw.name */
/** @see RawSong.name */
var name: String,
/** @see RealSong.Raw.sortName */
/** @see RawSong.sortName */
var sortName: String? = null,
/** @see RealSong.Raw.track */
/** @see RawSong.track */
var track: Int? = null,
/** @see RealSong.Raw.name */
/** @see RawSong.name */
var disc: Int? = null,
/** @See RealSong.Raw.subtitle */
/** @See RawSong.subtitle */
var subtitle: String? = null,
/** @see RealSong.Raw.date */
/** @see RawSong.date */
var date: Date? = null,
/** @see RealSong.Raw.albumMusicBrainzId */
/** @see RawSong.albumMusicBrainzId */
var albumMusicBrainzId: String? = null,
/** @see RealSong.Raw.albumName */
/** @see RawSong.albumName */
var albumName: String,
/** @see RealSong.Raw.albumSortName */
/** @see RawSong.albumSortName */
var albumSortName: String? = null,
/** @see RealSong.Raw.releaseTypes */
/** @see RawSong.releaseTypes */
var releaseTypes: List<String> = listOf(),
/** @see RealSong.Raw.artistMusicBrainzIds */
/** @see RawSong.artistMusicBrainzIds */
var artistMusicBrainzIds: List<String> = listOf(),
/** @see RealSong.Raw.artistNames */
/** @see RawSong.artistNames */
var artistNames: List<String> = listOf(),
/** @see RealSong.Raw.artistSortNames */
/** @see RawSong.artistSortNames */
var artistSortNames: List<String> = listOf(),
/** @see RealSong.Raw.albumArtistMusicBrainzIds */
/** @see RawSong.albumArtistMusicBrainzIds */
var albumArtistMusicBrainzIds: List<String> = listOf(),
/** @see RealSong.Raw.albumArtistNames */
/** @see RawSong.albumArtistNames */
var albumArtistNames: List<String> = listOf(),
/** @see RealSong.Raw.albumArtistSortNames */
/** @see RawSong.albumArtistSortNames */
var albumArtistSortNames: List<String> = listOf(),
/** @see RealSong.Raw.genreNames */
/** @see RawSong.genreNames */
var genreNames: List<String> = listOf()
) {
fun copyToRaw(rawSong: RealSong.Raw): CachedSong {
fun copyToRaw(rawSong: RawSong): CachedSong {
rawSong.musicBrainzId = musicBrainzId
rawSong.name = name
rawSong.sortName = sortName
@ -275,7 +276,7 @@ private data class CachedSong(
companion object {
const val TABLE_NAME = "cached_songs"
fun fromRaw(rawSong: RealSong.Raw) =
fun fromRaw(rawSong: RawSong) =
CachedSong(
mediaStoreId =
requireNotNull(rawSong.mediaStoreId) { "Invalid raw: No MediaStore ID" },

View file

@ -23,6 +23,7 @@ import com.google.android.exoplayer2.MediaItem
import com.google.android.exoplayer2.MetadataRetriever
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.yield
import org.oxycblt.auxio.music.library.RawSong
import org.oxycblt.auxio.music.library.RealSong
import org.oxycblt.auxio.music.metadata.Date
import org.oxycblt.auxio.music.metadata.TextTags
@ -45,10 +46,7 @@ class MetadataExtractor(private val context: Context) {
// producing similar throughput's to other kinds of manual metadata extraction.
private val taskPool: Array<Task?> = arrayOfNulls(TASK_CAPACITY)
suspend fun consume(
incompleteSongs: Channel<RealSong.Raw>,
completeSongs: Channel<RealSong.Raw>
) {
suspend fun consume(incompleteSongs: Channel<RawSong>, completeSongs: Channel<RawSong>) {
spin@ while (true) {
// Spin until there is an open slot we can insert a task in.
for (i in taskPool.indices) {
@ -97,12 +95,12 @@ class MetadataExtractor(private val context: Context) {
}
/**
* Wraps a [MetadataExtractor] future and processes it into a [RealSong.Raw] when completed.
* Wraps a [MetadataExtractor] future and processes it into a [RawSong] when completed.
* @param context [Context] required to open the audio file.
* @param raw [RealSong.Raw] to process.
* @param rawSong [RawSong] to process.
* @author Alexander Capehart (OxygenCobalt)
*/
private class Task(context: Context, private val raw: RealSong.Raw) {
private class Task(context: Context, private val rawSong: RawSong) {
// Note that we do not leverage future callbacks. This is because errors in the
// (highly fallible) extraction process will not bubble up to Indexer when a
// listener is used, instead crashing the app entirely.
@ -110,15 +108,13 @@ private class Task(context: Context, private val raw: RealSong.Raw) {
MetadataRetriever.retrieveMetadata(
context,
MediaItem.fromUri(
requireNotNull(raw.mediaStoreId) { "Invalid raw: No id" }.toAudioUri()))
init {}
requireNotNull(rawSong.mediaStoreId) { "Invalid raw: No id" }.toAudioUri()))
/**
* Try to get a completed song from this [Task], if it has finished processing.
* @return A [RealSong.Raw] instance if processing has completed, null otherwise.
* @return A [RawSong] instance if processing has completed, null otherwise.
*/
fun get(): RealSong.Raw? {
fun get(): RawSong? {
if (!future.isDone) {
// Not done yet, nothing to do.
return null
@ -128,13 +124,13 @@ private class Task(context: Context, private val raw: RealSong.Raw) {
try {
future.get()[0].getFormat(0)
} catch (e: Exception) {
logW("Unable to extract metadata for ${raw.name}")
logW("Unable to extract metadata for ${rawSong.name}")
logW(e.stackTraceToString())
null
}
if (format == null) {
logD("Nothing could be extracted for ${raw.name}")
return raw
logD("Nothing could be extracted for ${rawSong.name}")
return rawSong
}
val metadata = format.metadata
@ -143,29 +139,29 @@ private class Task(context: Context, private val raw: RealSong.Raw) {
populateWithId3v2(textTags.id3v2)
populateWithVorbis(textTags.vorbis)
} else {
logD("No metadata could be extracted for ${raw.name}")
logD("No metadata could be extracted for ${rawSong.name}")
}
return raw
return rawSong
}
/**
* Complete this instance's [RealSong.Raw] with ID3v2 Text Identification Frames.
* Complete this instance's [RawSong] with ID3v2 Text Identification Frames.
* @param textFrames A mapping between ID3v2 Text Identification Frame IDs and one or more
* values.
*/
private fun populateWithId3v2(textFrames: Map<String, List<String>>) {
// Song
textFrames["TXXX:musicbrainz release track id"]?.let { raw.musicBrainzId = it.first() }
textFrames["TIT2"]?.let { raw.name = it.first() }
textFrames["TSOT"]?.let { raw.sortName = it.first() }
textFrames["TXXX:musicbrainz release track id"]?.let { rawSong.musicBrainzId = it.first() }
textFrames["TIT2"]?.let { rawSong.name = it.first() }
textFrames["TSOT"]?.let { rawSong.sortName = it.first() }
// Track.
textFrames["TRCK"]?.run { first().parseId3v2PositionField() }?.let { raw.track = it }
textFrames["TRCK"]?.run { first().parseId3v2PositionField() }?.let { rawSong.track = it }
// Disc and it's subtitle name.
textFrames["TPOS"]?.run { first().parseId3v2PositionField() }?.let { raw.disc = it }
textFrames["TSST"]?.let { raw.subtitle = it.first() }
textFrames["TPOS"]?.run { first().parseId3v2PositionField() }?.let { rawSong.disc = it }
textFrames["TSST"]?.let { rawSong.subtitle = it.first() }
// Dates are somewhat complicated, as not only did their semantics change from a flat year
// value in ID3v2.3 to a full ISO-8601 date in ID3v2.4, but there are also a variety of
@ -180,30 +176,36 @@ private class Task(context: Context, private val raw: RealSong.Raw) {
?: textFrames["TDRC"]?.run { Date.from(first()) }
?: textFrames["TDRL"]?.run { Date.from(first()) }
?: parseId3v23Date(textFrames))
?.let { raw.date = it }
?.let { rawSong.date = it }
// Album
textFrames["TXXX:musicbrainz album id"]?.let { raw.albumMusicBrainzId = it.first() }
textFrames["TALB"]?.let { raw.albumName = it.first() }
textFrames["TSOA"]?.let { raw.albumSortName = it.first() }
textFrames["TXXX:musicbrainz album id"]?.let { rawSong.albumMusicBrainzId = it.first() }
textFrames["TALB"]?.let { rawSong.albumName = it.first() }
textFrames["TSOA"]?.let { rawSong.albumSortName = it.first() }
(textFrames["TXXX:musicbrainz album type"] ?: textFrames["GRP1"])?.let {
raw.releaseTypes = it
rawSong.releaseTypes = it
}
// Artist
textFrames["TXXX:musicbrainz artist id"]?.let { raw.artistMusicBrainzIds = it }
(textFrames["TXXX:artists"] ?: textFrames["TPE1"])?.let { raw.artistNames = it }
(textFrames["TXXX:artists_sort"] ?: textFrames["TSOP"])?.let { raw.artistSortNames = it }
textFrames["TXXX:musicbrainz artist id"]?.let { rawSong.artistMusicBrainzIds = it }
(textFrames["TXXX:artists"] ?: textFrames["TPE1"])?.let { rawSong.artistNames = it }
(textFrames["TXXX:artists_sort"] ?: textFrames["TSOP"])?.let {
rawSong.artistSortNames = it
}
// Album artist
textFrames["TXXX:musicbrainz album artist id"]?.let { raw.albumArtistMusicBrainzIds = it }
(textFrames["TXXX:albumartists"] ?: textFrames["TPE2"])?.let { raw.albumArtistNames = it }
textFrames["TXXX:musicbrainz album artist id"]?.let {
rawSong.albumArtistMusicBrainzIds = it
}
(textFrames["TXXX:albumartists"] ?: textFrames["TPE2"])?.let {
rawSong.albumArtistNames = it
}
(textFrames["TXXX:albumartists_sort"] ?: textFrames["TSO2"])?.let {
raw.albumArtistSortNames = it
rawSong.albumArtistSortNames = it
}
// Genre
textFrames["TCON"]?.let { raw.genreNames = it }
textFrames["TCON"]?.let { rawSong.genreNames = it }
}
/**
@ -249,27 +251,27 @@ private class Task(context: Context, private val raw: RealSong.Raw) {
}
/**
* Complete this instance's [RealSong.Raw] with Vorbis comments.
* Complete this instance's [RawSong] with Vorbis comments.
* @param comments A mapping between vorbis comment names and one or more vorbis comment values.
*/
private fun populateWithVorbis(comments: Map<String, List<String>>) {
// Song
comments["musicbrainz_releasetrackid"]?.let { raw.musicBrainzId = it.first() }
comments["title"]?.let { raw.name = it.first() }
comments["titlesort"]?.let { raw.sortName = it.first() }
comments["musicbrainz_releasetrackid"]?.let { rawSong.musicBrainzId = it.first() }
comments["title"]?.let { rawSong.name = it.first() }
comments["titlesort"]?.let { rawSong.sortName = it.first() }
// Track.
parseVorbisPositionField(
comments["tracknumber"]?.first(),
(comments["totaltracks"] ?: comments["tracktotal"] ?: comments["trackc"])?.first())
?.let { raw.track = it }
?.let { rawSong.track = it }
// Disc and it's subtitle name.
parseVorbisPositionField(
comments["discnumber"]?.first(),
(comments["totaldiscs"] ?: comments["disctotal"] ?: comments["discc"])?.first())
?.let { raw.disc = it }
comments["discsubtitle"]?.let { raw.subtitle = it.first() }
?.let { rawSong.disc = it }
comments["discsubtitle"]?.let { rawSong.subtitle = it.first() }
// Vorbis dates are less complicated, but there are still several types
// Our hierarchy for dates is as such:
@ -280,27 +282,27 @@ private class Task(context: Context, private val raw: RealSong.Raw) {
(comments["originaldate"]?.run { Date.from(first()) }
?: comments["date"]?.run { Date.from(first()) }
?: comments["year"]?.run { Date.from(first()) })
?.let { raw.date = it }
?.let { rawSong.date = it }
// Album
comments["musicbrainz_albumid"]?.let { raw.albumMusicBrainzId = it.first() }
comments["album"]?.let { raw.albumName = it.first() }
comments["albumsort"]?.let { raw.albumSortName = it.first() }
comments["releasetype"]?.let { raw.releaseTypes = it }
comments["musicbrainz_albumid"]?.let { rawSong.albumMusicBrainzId = it.first() }
comments["album"]?.let { rawSong.albumName = it.first() }
comments["albumsort"]?.let { rawSong.albumSortName = it.first() }
comments["releasetype"]?.let { rawSong.releaseTypes = it }
// Artist
comments["musicbrainz_artistid"]?.let { raw.artistMusicBrainzIds = it }
(comments["artists"] ?: comments["artist"])?.let { raw.artistNames = it }
(comments["artists_sort"] ?: comments["artistsort"])?.let { raw.artistSortNames = it }
comments["musicbrainz_artistid"]?.let { rawSong.artistMusicBrainzIds = it }
(comments["artists"] ?: comments["artist"])?.let { rawSong.artistNames = it }
(comments["artists_sort"] ?: comments["artistsort"])?.let { rawSong.artistSortNames = it }
// Album artist
comments["musicbrainz_albumartistid"]?.let { raw.albumArtistMusicBrainzIds = it }
(comments["albumartists"] ?: comments["albumartist"])?.let { raw.albumArtistNames = it }
comments["musicbrainz_albumartistid"]?.let { rawSong.albumArtistMusicBrainzIds = it }
(comments["albumartists"] ?: comments["albumartist"])?.let { rawSong.albumArtistNames = it }
(comments["albumartists_sort"] ?: comments["albumartistsort"])?.let {
raw.albumArtistSortNames = it
rawSong.albumArtistSortNames = it
}
// Genre
comments["genre"]?.let { raw.genreNames = it }
comments["genre"]?.let { rawSong.genreNames = it }
}
}

View file

@ -77,15 +77,15 @@ interface Library {
companion object {
/**
* Create a library-backed instance of [Library].
* @param rawSongs [RealSong.Raw]s to create the library out of.
* @param rawSongs [RawSong]s to create the library out of.
* @param settings [MusicSettings] required.
*/
fun from(rawSongs: List<RealSong.Raw>, settings: MusicSettings): Library =
fun from(rawSongs: List<RawSong>, settings: MusicSettings): Library =
RealLibrary(rawSongs, settings)
}
}
private class RealLibrary(rawSongs: List<RealSong.Raw>, settings: MusicSettings) : Library {
private class RealLibrary(rawSongs: List<RawSong>, settings: MusicSettings) : Library {
override val songs = buildSongs(rawSongs, settings)
override val albums = buildAlbums(songs)
override val artists = buildArtists(songs, albums)
@ -124,13 +124,13 @@ private class RealLibrary(rawSongs: List<RealSong.Raw>, settings: MusicSettings)
}
/**
* Build a list [RealSong]s from the given [RealSong.Raw].
* @param rawSongs The [RealSong.Raw]s to build the [RealSong]s from.
* Build a list [RealSong]s from the given [RawSong].
* @param rawSongs The [RawSong]s to build the [RealSong]s from.
* @param settings [MusicSettings] required to build [RealSong]s.
* @return A sorted list of [RealSong]s derived from the [RealSong.Raw] that should be suitable
* for grouping.
* @return A sorted list of [RealSong]s derived from the [RawSong] that should be suitable for
* grouping.
*/
private fun buildSongs(rawSongs: List<RealSong.Raw>, settings: MusicSettings) =
private fun buildSongs(rawSongs: List<RawSong>, settings: MusicSettings) =
Sort(Sort.Mode.ByName, true).songs(rawSongs.map { RealSong(it, settings) }.distinct())
/**
@ -165,7 +165,7 @@ private class RealLibrary(rawSongs: List<RealSong.Raw>, settings: MusicSettings)
private fun buildArtists(songs: List<RealSong>, albums: List<RealAlbum>): List<RealArtist> {
// Add every raw artist credited to each Song/Album to the grouping. This way,
// different multi-artist combinations are not treated as different artists.
val musicByArtist = mutableMapOf<RealArtist.Raw, MutableList<Music>>()
val musicByArtist = mutableMapOf<RawArtist, MutableList<Music>>()
for (song in songs) {
for (rawArtist in song.rawArtists) {
@ -195,7 +195,7 @@ private class RealLibrary(rawSongs: List<RealSong.Raw>, settings: MusicSettings)
private fun buildGenres(songs: List<RealSong>): List<RealGenre> {
// Add every raw genre credited to each Song to the grouping. This way,
// different multi-genre combinations are not treated as different genres.
val songsByGenre = mutableMapOf<RealGenre.Raw, MutableList<RealSong>>()
val songsByGenre = mutableMapOf<RawGenre, MutableList<RealSong>>()
for (song in songs) {
for (rawGenre in song.rawGenres) {
songsByGenre.getOrPut(rawGenre) { mutableListOf() }.add(song)

View file

@ -0,0 +1,190 @@
/*
* Copyright (c) 2023 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.library
import java.util.UUID
import org.oxycblt.auxio.music.*
import org.oxycblt.auxio.music.metadata.*
import org.oxycblt.auxio.music.storage.Directory
/** Raw information about a [RealSong] obtained from the filesystem/Extractor instances. */
class RawSong(
/**
* The ID of the [RealSong]'s audio file, obtained from MediaStore. Note that this ID is highly
* unstable and should only be used for accessing the audio file.
*/
var mediaStoreId: Long? = null,
/** @see Song.dateAdded */
var dateAdded: Long? = null,
/** The latest date the [RealSong]'s audio file was modified, as a unix epoch timestamp. */
var dateModified: Long? = null,
/** @see Song.path */
var fileName: String? = null,
/** @see Song.path */
var directory: Directory? = null,
/** @see Song.size */
var size: Long? = null,
/** @see Song.durationMs */
var durationMs: Long? = null,
/** @see Song.mimeType */
var extensionMimeType: String? = null,
/** @see Music.UID */
var musicBrainzId: String? = null,
/** @see Music.rawName */
var name: String? = null,
/** @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 RawAlbum.mediaStoreId */
var albumMediaStoreId: Long? = null,
/** @see RawAlbum.musicBrainzId */
var albumMusicBrainzId: String? = null,
/** @see RawAlbum.name */
var albumName: String? = null,
/** @see RawAlbum.sortName */
var albumSortName: String? = null,
/** @see RawAlbum.releaseType */
var releaseTypes: List<String> = listOf(),
/** @see RawArtist.musicBrainzId */
var artistMusicBrainzIds: List<String> = listOf(),
/** @see RawArtist.name */
var artistNames: List<String> = listOf(),
/** @see RawArtist.sortName */
var artistSortNames: List<String> = listOf(),
/** @see RawArtist.musicBrainzId */
var albumArtistMusicBrainzIds: List<String> = listOf(),
/** @see RawArtist.name */
var albumArtistNames: List<String> = listOf(),
/** @see RawArtist.sortName */
var albumArtistSortNames: List<String> = listOf(),
/** @see RawGenre.name */
var genreNames: List<String> = listOf()
)
/** Raw information about an [RealAlbum] obtained from the component [RealSong] instances. */
class RawAlbum(
/**
* The ID of the [RealAlbum]'s grouping, obtained from MediaStore. Note that this ID is highly
* unstable and should only be used for accessing the system-provided cover art.
*/
val mediaStoreId: Long,
/** @see Music.uid */
val musicBrainzId: UUID?,
/** @see Music.rawName */
val name: String,
/** @see Music.rawSortName */
val sortName: String?,
/** @see Album.releaseType */
val releaseType: ReleaseType?,
/** @see RawArtist.name */
val rawArtists: List<RawArtist>
) {
// Albums are grouped as follows:
// - If we have a MusicBrainz ID, only group by it. This allows different Albums with the
// same name to be differentiated, which is common in large libraries.
// - If we do not have a MusicBrainz ID, compare by the lowercase album name and lowercase
// artist name. This allows for case-insensitive artist/album grouping, which can be common
// for albums/artists that have different naming (ex. "RAMMSTEIN" vs. "Rammstein").
// Cache the hash-code for HashMap efficiency.
private val hashCode =
musicBrainzId?.hashCode() ?: (31 * name.lowercase().hashCode() + rawArtists.hashCode())
override fun hashCode() = hashCode
override fun equals(other: Any?) =
other is RawAlbum &&
when {
musicBrainzId != null && other.musicBrainzId != null ->
musicBrainzId == other.musicBrainzId
musicBrainzId == null && other.musicBrainzId == null ->
name.equals(other.name, true) && rawArtists == other.rawArtists
else -> false
}
}
/**
* Raw information about an [RealArtist] obtained from the component [RealSong] and [RealAlbum]
* instances.
*/
class RawArtist(
/** @see Music.UID */
val musicBrainzId: UUID? = null,
/** @see Music.rawName */
val name: String? = null,
/** @see Music.rawSortName */
val sortName: String? = null
) {
// Artists are grouped as follows:
// - If we have a MusicBrainz ID, only group by it. This allows different Artists with the
// same name to be differentiated, which is common in large libraries.
// - If we do not have a MusicBrainz ID, compare by the lowercase name. This allows artist
// grouping to be case-insensitive.
// Cache the hashCode for HashMap efficiency.
private val hashCode = musicBrainzId?.hashCode() ?: name?.lowercase().hashCode()
// Compare names and MusicBrainz IDs in order to differentiate artists with the
// same name in large libraries.
override fun hashCode() = hashCode
override fun equals(other: Any?) =
other is RawArtist &&
when {
musicBrainzId != null && other.musicBrainzId != null ->
musicBrainzId == other.musicBrainzId
musicBrainzId == null && other.musicBrainzId == null ->
when {
name != null && other.name != null -> name.equals(other.name, true)
name == null && other.name == null -> true
else -> false
}
else -> false
}
}
/** Raw information about a [RealGenre] obtained from the component [RealSong] instances. */
class RawGenre(
/** @see Music.rawName */
val name: String? = null
) {
// Only group by the lowercase genre name. This allows Genre grouping to be
// case-insensitive, which may be helpful in some libraries with different ways of
// formatting genres.
// Cache the hashCode for HashMap efficiency.
private val hashCode = name?.lowercase().hashCode()
override fun hashCode() = hashCode
override fun equals(other: Any?) =
other is RawGenre &&
when {
name != null && other.name != null -> name.equals(other.name, true)
name == null && other.name == null -> true
else -> false
}
}

View file

@ -22,7 +22,6 @@ import androidx.annotation.VisibleForTesting
import java.security.MessageDigest
import java.text.CollationKey
import java.text.Collator
import java.util.UUID
import kotlin.math.max
import org.oxycblt.auxio.R
import org.oxycblt.auxio.music.Album
@ -37,7 +36,6 @@ import org.oxycblt.auxio.music.metadata.Disc
import org.oxycblt.auxio.music.metadata.ReleaseType
import org.oxycblt.auxio.music.parsing.parseId3GenreNames
import org.oxycblt.auxio.music.parsing.parseMultiValue
import org.oxycblt.auxio.music.storage.Directory
import org.oxycblt.auxio.music.storage.MimeType
import org.oxycblt.auxio.music.storage.Path
import org.oxycblt.auxio.music.storage.toAudioUri
@ -46,52 +44,51 @@ import org.oxycblt.auxio.util.nonZeroOrNull
import org.oxycblt.auxio.util.toUuidOrNull
import org.oxycblt.auxio.util.unlikelyToBeNull
// TODO: Split off raw music and real music
/**
* Library-backed implementation of [RealSong].
* @param raw The [Raw] to derive the member data from.
* @param rawSong The [RawSong] to derive the member data from.
* @param musicSettings [MusicSettings] to perform further user-configured parsing.
* @author Alexander Capehart (OxygenCobalt)
*/
class RealSong(raw: Raw, musicSettings: MusicSettings) : Song {
class RealSong(rawSong: RawSong, musicSettings: MusicSettings) : Song {
override val uid =
// Attempt to use a MusicBrainz ID first before falling back to a hashed UID.
raw.musicBrainzId?.toUuidOrNull()?.let { Music.UID.musicBrainz(MusicMode.SONGS, it) }
rawSong.musicBrainzId?.toUuidOrNull()?.let { Music.UID.musicBrainz(MusicMode.SONGS, it) }
?: Music.UID.auxio(MusicMode.SONGS) {
// Song UIDs are based on the raw data without parsing so that they remain
// consistent across music setting changes. Parents are not held up to the
// same standard since grouping is already inherently linked to settings.
update(raw.name)
update(raw.albumName)
update(raw.date)
update(rawSong.name)
update(rawSong.albumName)
update(rawSong.date)
update(raw.track)
update(raw.disc)
update(rawSong.track)
update(rawSong.disc)
update(raw.artistNames)
update(raw.albumArtistNames)
update(rawSong.artistNames)
update(rawSong.albumArtistNames)
}
override val rawName = requireNotNull(raw.name) { "Invalid raw: No title" }
override val rawSortName = raw.sortName
override val rawName = requireNotNull(rawSong.name) { "Invalid raw: No title" }
override val rawSortName = rawSong.sortName
override val collationKey = makeCollationKey(this)
override fun resolveName(context: Context) = rawName
override val track = raw.track
override val disc = raw.disc?.let { Disc(it, raw.subtitle) }
override val date = raw.date
override val uri = requireNotNull(raw.mediaStoreId) { "Invalid raw: No id" }.toAudioUri()
override val track = rawSong.track
override val disc = rawSong.disc?.let { Disc(it, rawSong.subtitle) }
override val date = rawSong.date
override val uri = requireNotNull(rawSong.mediaStoreId) { "Invalid raw: No id" }.toAudioUri()
override val path =
Path(
name = requireNotNull(raw.fileName) { "Invalid raw: No display name" },
parent = requireNotNull(raw.directory) { "Invalid raw: No parent directory" })
name = requireNotNull(rawSong.fileName) { "Invalid raw: No display name" },
parent = requireNotNull(rawSong.directory) { "Invalid raw: No parent directory" })
override val mimeType =
MimeType(
fromExtension = requireNotNull(raw.extensionMimeType) { "Invalid raw: No mime type" },
fromExtension =
requireNotNull(rawSong.extensionMimeType) { "Invalid raw: No mime type" },
fromFormat = null)
override val size = requireNotNull(raw.size) { "Invalid raw: No size" }
override val durationMs = requireNotNull(raw.durationMs) { "Invalid raw: No duration" }
override val dateAdded = requireNotNull(raw.dateAdded) { "Invalid raw: No date added" }
override val size = requireNotNull(rawSong.size) { "Invalid raw: No size" }
override val durationMs = requireNotNull(rawSong.durationMs) { "Invalid raw: No duration" }
override val dateAdded = requireNotNull(rawSong.dateAdded) { "Invalid raw: No date added" }
private var _album: RealAlbum? = null
override val album: Album
get() = unlikelyToBeNull(_album)
@ -100,24 +97,24 @@ class RealSong(raw: Raw, musicSettings: MusicSettings) : Song {
override fun hashCode() = uid.hashCode()
override fun equals(other: Any?) = other is Song && uid == other.uid
private val artistMusicBrainzIds = raw.artistMusicBrainzIds.parseMultiValue(musicSettings)
private val artistNames = raw.artistNames.parseMultiValue(musicSettings)
private val artistSortNames = raw.artistSortNames.parseMultiValue(musicSettings)
private val artistMusicBrainzIds = rawSong.artistMusicBrainzIds.parseMultiValue(musicSettings)
private val artistNames = rawSong.artistNames.parseMultiValue(musicSettings)
private val artistSortNames = rawSong.artistSortNames.parseMultiValue(musicSettings)
private val rawIndividualArtists =
artistNames.mapIndexed { i, name ->
RealArtist.Raw(
RawArtist(
artistMusicBrainzIds.getOrNull(i)?.toUuidOrNull(),
name,
artistSortNames.getOrNull(i))
}
private val albumArtistMusicBrainzIds =
raw.albumArtistMusicBrainzIds.parseMultiValue(musicSettings)
private val albumArtistNames = raw.albumArtistNames.parseMultiValue(musicSettings)
private val albumArtistSortNames = raw.albumArtistSortNames.parseMultiValue(musicSettings)
rawSong.albumArtistMusicBrainzIds.parseMultiValue(musicSettings)
private val albumArtistNames = rawSong.albumArtistNames.parseMultiValue(musicSettings)
private val albumArtistSortNames = rawSong.albumArtistSortNames.parseMultiValue(musicSettings)
private val rawAlbumArtists =
albumArtistNames.mapIndexed { i, name ->
RealArtist.Raw(
RawArtist(
albumArtistMusicBrainzIds.getOrNull(i)?.toUuidOrNull(),
name,
albumArtistSortNames.getOrNull(i))
@ -145,39 +142,38 @@ class RealSong(raw: Raw, musicSettings: MusicSettings) : Song {
override fun resolveGenreContents(context: Context) = resolveNames(context, genres)
/**
* The [RealAlbum.Raw] instances collated by the [RealSong]. This can be used to group
* [RealSong]s into an [RealAlbum].
* The [RawAlbum] instances collated by the [RealSong]. This can be used to group [RealSong]s
* into an [RealAlbum].
*/
val rawAlbum =
RealAlbum.Raw(
mediaStoreId = requireNotNull(raw.albumMediaStoreId) { "Invalid raw: No album id" },
musicBrainzId = raw.albumMusicBrainzId?.toUuidOrNull(),
name = requireNotNull(raw.albumName) { "Invalid raw: No album name" },
sortName = raw.albumSortName,
releaseType = ReleaseType.parse(raw.releaseTypes.parseMultiValue(musicSettings)),
RawAlbum(
mediaStoreId = requireNotNull(rawSong.albumMediaStoreId) { "Invalid raw: No album id" },
musicBrainzId = rawSong.albumMusicBrainzId?.toUuidOrNull(),
name = requireNotNull(rawSong.albumName) { "Invalid raw: No album name" },
sortName = rawSong.albumSortName,
releaseType = ReleaseType.parse(rawSong.releaseTypes.parseMultiValue(musicSettings)),
rawArtists =
rawAlbumArtists
.ifEmpty { rawIndividualArtists }
.ifEmpty { listOf(RealArtist.Raw(null, null)) })
.ifEmpty { listOf(RawArtist(null, null)) })
/**
* The [RealArtist.Raw] instances collated by the [RealSong]. The artists of the song take
* priority, followed by the album artists. If there are no artists, this field will be a single
* "unknown" [RealArtist.Raw]. This can be used to group up [RealSong]s into an [RealArtist].
* The [RawArtist] instances collated by the [RealSong]. The artists of the song take priority,
* followed by the album artists. If there are no artists, this field will be a single "unknown"
* [RawArtist]. This can be used to group up [RealSong]s into an [RealArtist].
*/
val rawArtists =
rawIndividualArtists.ifEmpty { rawAlbumArtists }.ifEmpty { listOf(RealArtist.Raw()) }
rawIndividualArtists.ifEmpty { rawAlbumArtists }.ifEmpty { listOf(RawArtist()) }
/**
* The [RealGenre.Raw] instances collated by the [RealSong]. This can be used to group up
* [RealSong]s into a [RealGenre]. ID3v2 Genre names are automatically converted to their
* resolved names.
* The [RawGenre] instances collated by the [RealSong]. This can be used to group up [RealSong]s
* into a [RealGenre]. ID3v2 Genre names are automatically converted to their resolved names.
*/
val rawGenres =
raw.genreNames
rawSong.genreNames
.parseId3GenreNames(musicSettings)
.map { RealGenre.Raw(it) }
.ifEmpty { listOf(RealGenre.Raw()) }
.map { RawGenre(it) }
.ifEmpty { listOf(RawGenre()) }
/**
* Links this [RealSong] with a parent [RealAlbum].
@ -231,95 +227,34 @@ class RealSong(raw: Raw, musicSettings: MusicSettings) : Song {
}
return this
}
/** Raw information about a [RealSong] obtained from the filesystem/Extractor instances. */
class Raw(
/**
* The ID of the [RealSong]'s audio file, obtained from MediaStore. Note that this ID is
* highly unstable and should only be used for accessing the audio file.
*/
var mediaStoreId: Long? = null,
/** @see Song.dateAdded */
var dateAdded: Long? = null,
/** The latest date the [RealSong]'s audio file was modified, as a unix epoch timestamp. */
var dateModified: Long? = null,
/** @see Song.path */
var fileName: String? = null,
/** @see Song.path */
var directory: Directory? = null,
/** @see Song.size */
var size: Long? = null,
/** @see Song.durationMs */
var durationMs: Long? = null,
/** @see Song.mimeType */
var extensionMimeType: String? = null,
/** @see Music.UID */
var musicBrainzId: String? = null,
/** @see Music.rawName */
var name: String? = null,
/** @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 RealAlbum.Raw.mediaStoreId */
var albumMediaStoreId: Long? = null,
/** @see RealAlbum.Raw.musicBrainzId */
var albumMusicBrainzId: String? = null,
/** @see RealAlbum.Raw.name */
var albumName: String? = null,
/** @see RealAlbum.Raw.sortName */
var albumSortName: String? = null,
/** @see RealAlbum.Raw.releaseType */
var releaseTypes: List<String> = listOf(),
/** @see RealArtist.Raw.musicBrainzId */
var artistMusicBrainzIds: List<String> = listOf(),
/** @see RealArtist.Raw.name */
var artistNames: List<String> = listOf(),
/** @see RealArtist.Raw.sortName */
var artistSortNames: List<String> = listOf(),
/** @see RealArtist.Raw.musicBrainzId */
var albumArtistMusicBrainzIds: List<String> = listOf(),
/** @see RealArtist.Raw.name */
var albumArtistNames: List<String> = listOf(),
/** @see RealArtist.Raw.sortName */
var albumArtistSortNames: List<String> = listOf(),
/** @see RealGenre.Raw.name */
var genreNames: List<String> = listOf()
)
}
/**
* Library-backed implementation of [RealAlbum].
* @param raw The [RealAlbum.Raw] to derive the member data from.
* @param rawAlbum The [RawAlbum] to derive the member data from.
* @param songs The [RealSong]s that are a part of this [RealAlbum]. These items will be linked to
* this [RealAlbum].
* @author Alexander Capehart (OxygenCobalt)
*/
class RealAlbum(val raw: Raw, override val songs: List<RealSong>) : Album {
class RealAlbum(val rawAlbum: RawAlbum, override val songs: List<RealSong>) : Album {
override val uid =
// Attempt to use a MusicBrainz ID first before falling back to a hashed UID.
raw.musicBrainzId?.let { Music.UID.musicBrainz(MusicMode.ALBUMS, it) }
rawAlbum.musicBrainzId?.let { Music.UID.musicBrainz(MusicMode.ALBUMS, it) }
?: Music.UID.auxio(MusicMode.ALBUMS) {
// Hash based on only names despite the presence of a date to increase stability.
// I don't know if there is any situation where an artist will have two albums with
// the exact same name, but if there is, I would love to know.
update(raw.name)
update(raw.rawArtists.map { it.name })
update(rawAlbum.name)
update(rawAlbum.rawArtists.map { it.name })
}
override val rawName = raw.name
override val rawSortName = raw.sortName
override val rawName = rawAlbum.name
override val rawSortName = rawAlbum.sortName
override val collationKey = makeCollationKey(this)
override fun resolveName(context: Context) = rawName
override val dates = Date.Range.from(songs.mapNotNull { it.date })
override val releaseType = raw.releaseType ?: ReleaseType.Album(null)
override val coverUri = raw.mediaStoreId.toCoverUri()
override val releaseType = rawAlbum.releaseType ?: ReleaseType.Album(null)
override val coverUri = rawAlbum.mediaStoreId.toCoverUri()
override val durationMs: Long
override val dateAdded: Long
@ -362,11 +297,11 @@ class RealAlbum(val raw: Raw, override val songs: List<RealSong>) : Album {
}
/**
* The [RealArtist.Raw] instances collated by the [RealAlbum]. The album artists of the song
* take priority, followed by the artists. If there are no artists, this field will be a single
* "unknown" [RealArtist.Raw]. This can be used to group up [RealAlbum]s into an [RealArtist].
* The [RawArtist] instances collated by the [RealAlbum]. The album artists of the song take
* priority, followed by the artists. If there are no artists, this field will be a single
* "unknown" [RawArtist]. This can be used to group up [RealAlbum]s into an [RealArtist].
*/
val rawArtists = raw.rawArtists
val rawArtists = rawAlbum.rawArtists
/**
* Links this [RealAlbum] with a parent [RealArtist].
@ -393,65 +328,23 @@ class RealAlbum(val raw: Raw, override val songs: List<RealSong>) : Album {
}
return this
}
/** Raw information about an [RealAlbum] obtained from the component [RealSong] instances. */
class Raw(
/**
* The ID of the [RealAlbum]'s grouping, obtained from MediaStore. Note that this ID is
* highly unstable and should only be used for accessing the system-provided cover art.
*/
val mediaStoreId: Long,
/** @see Music.uid */
val musicBrainzId: UUID?,
/** @see Music.rawName */
val name: String,
/** @see Music.rawSortName */
val sortName: String?,
/** @see Album.releaseType */
val releaseType: ReleaseType?,
/** @see RealArtist.Raw.name */
val rawArtists: List<RealArtist.Raw>
) {
// Albums are grouped as follows:
// - If we have a MusicBrainz ID, only group by it. This allows different Albums with the
// same name to be differentiated, which is common in large libraries.
// - If we do not have a MusicBrainz ID, compare by the lowercase album name and lowercase
// artist name. This allows for case-insensitive artist/album grouping, which can be common
// for albums/artists that have different naming (ex. "RAMMSTEIN" vs. "Rammstein").
// Cache the hash-code for HashMap efficiency.
private val hashCode =
musicBrainzId?.hashCode() ?: (31 * name.lowercase().hashCode() + rawArtists.hashCode())
override fun hashCode() = hashCode
override fun equals(other: Any?) =
other is Raw &&
when {
musicBrainzId != null && other.musicBrainzId != null ->
musicBrainzId == other.musicBrainzId
musicBrainzId == null && other.musicBrainzId == null ->
name.equals(other.name, true) && rawArtists == other.rawArtists
else -> false
}
}
}
/**
* Library-backed implementation of [RealArtist].
* @param raw The [RealArtist.Raw] to derive the member data from.
* @param rawArtist The [RawArtist] to derive the member data from.
* @param songAlbums A list of the [RealSong]s and [RealAlbum]s that are a part of this [RealArtist]
* , either through artist or album artist tags. Providing [RealSong]s to the artist is optional.
* These instances will be linked to this [RealArtist].
* @author Alexander Capehart (OxygenCobalt)
*/
class RealArtist(private val raw: Raw, songAlbums: List<Music>) : Artist {
class RealArtist(private val rawArtist: RawArtist, songAlbums: List<Music>) : Artist {
override val uid =
// Attempt to use a MusicBrainz ID first before falling back to a hashed UID.
raw.musicBrainzId?.let { Music.UID.musicBrainz(MusicMode.ARTISTS, it) }
?: Music.UID.auxio(MusicMode.ARTISTS) { update(raw.name) }
override val rawName = raw.name
override val rawSortName = raw.sortName
rawArtist.musicBrainzId?.let { Music.UID.musicBrainz(MusicMode.ARTISTS, it) }
?: Music.UID.auxio(MusicMode.ARTISTS) { update(rawArtist.name) }
override val rawName = rawArtist.name
override val rawSortName = rawArtist.sortName
override val collationKey = makeCollationKey(this)
override fun resolveName(context: Context) = rawName ?: context.getString(R.string.def_artist)
override val songs: List<Song>
@ -509,14 +402,14 @@ class RealArtist(private val raw: Raw, songAlbums: List<Music>) : Artist {
}
/**
* Returns the original position of this [RealArtist]'s [RealArtist.Raw] within the given
* [RealArtist.Raw] list. This can be used to create a consistent ordering within child
* [RealArtist] lists based on the original tag order.
* @param rawArtists The [RealArtist.Raw] instances to check. It is assumed that this
* [RealArtist]'s [RealArtist.Raw] will be within the list.
* @return The index of the [RealArtist]'s [RealArtist.Raw] within the list.
* Returns the original position of this [RealArtist]'s [RawArtist] within the given [RawArtist]
* list. This can be used to create a consistent ordering within child [RealArtist] lists based
* on the original tag order.
* @param rawArtists The [RawArtist] instances to check. It is assumed that this [RealArtist]'s
* [RawArtist] will be within the list.
* @return The index of the [RealArtist]'s [RawArtist] within the list.
*/
fun getOriginalPositionIn(rawArtists: List<Raw>) = rawArtists.indexOf(raw)
fun getOriginalPositionIn(rawArtists: List<RawArtist>) = rawArtists.indexOf(rawArtist)
/**
* Perform final validation and organization on this instance.
@ -530,55 +423,14 @@ class RealArtist(private val raw: Raw, songAlbums: List<Music>) : Artist {
.sortedByDescending { genre -> songs.count { it.genres.contains(genre) } }
return this
}
/**
* Raw information about an [RealArtist] obtained from the component [RealSong] and [RealAlbum]
* instances.
*/
class Raw(
/** @see Music.UID */
val musicBrainzId: UUID? = null,
/** @see Music.rawName */
val name: String? = null,
/** @see Music.rawSortName */
val sortName: String? = null
) {
// Artists are grouped as follows:
// - If we have a MusicBrainz ID, only group by it. This allows different Artists with the
// same name to be differentiated, which is common in large libraries.
// - If we do not have a MusicBrainz ID, compare by the lowercase name. This allows artist
// grouping to be case-insensitive.
// Cache the hashCode for HashMap efficiency.
private val hashCode = musicBrainzId?.hashCode() ?: name?.lowercase().hashCode()
// Compare names and MusicBrainz IDs in order to differentiate artists with the
// same name in large libraries.
override fun hashCode() = hashCode
override fun equals(other: Any?) =
other is Raw &&
when {
musicBrainzId != null && other.musicBrainzId != null ->
musicBrainzId == other.musicBrainzId
musicBrainzId == null && other.musicBrainzId == null ->
when {
name != null && other.name != null -> name.equals(other.name, true)
name == null && other.name == null -> true
else -> false
}
else -> false
}
}
}
/**
* Library-backed implementation of [RealGenre].
* @author Alexander Capehart (OxygenCobalt)
*/
class RealGenre(private val raw: Raw, override val songs: List<RealSong>) : Genre {
override val uid = Music.UID.auxio(MusicMode.GENRES) { update(raw.name) }
override val rawName = raw.name
class RealGenre(private val rawGenre: RawGenre, override val songs: List<RealSong>) : Genre {
override val uid = Music.UID.auxio(MusicMode.GENRES) { update(rawGenre.name) }
override val rawName = rawGenre.name
override val rawSortName = rawName
override val collationKey = makeCollationKey(this)
override fun resolveName(context: Context) = rawName ?: context.getString(R.string.def_genre)
@ -614,14 +466,14 @@ class RealGenre(private val raw: Raw, override val songs: List<RealSong>) : Genr
}
/**
* Returns the original position of this [RealGenre]'s [RealGenre.Raw] within the given
* [RealGenre.Raw] list. This can be used to create a consistent ordering within child
* [RealGenre] lists based on the original tag order.
* @param rawGenres The [RealGenre.Raw] instances to check. It is assumed that this [RealGenre]
* 's [RealGenre.Raw] will be within the list.
* @return The index of the [RealGenre]'s [RealGenre.Raw] within the list.
* Returns the original position of this [RealGenre]'s [RawGenre] within the given [RawGenre]
* list. This can be used to create a consistent ordering within child [RealGenre] lists based
* on the original tag order.
* @param rawGenres The [RawGenre] instances to check. It is assumed that this [RealGenre] 's
* [RawGenre] will be within the list.
* @return The index of the [RealGenre]'s [RawGenre] within the list.
*/
fun getOriginalPositionIn(rawGenres: List<Raw>) = rawGenres.indexOf(raw)
fun getOriginalPositionIn(rawGenres: List<RawGenre>) = rawGenres.indexOf(rawGenre)
/**
* Perform final validation and organization on this instance.
@ -631,29 +483,6 @@ class RealGenre(private val raw: Raw, override val songs: List<RealSong>) : Genr
check(songs.isNotEmpty()) { "Malformed genre: Empty" }
return this
}
/** Raw information about a [RealGenre] obtained from the component [RealSong] instances. */
class Raw(
/** @see Music.rawName */
val name: String? = null
) {
// Only group by the lowercase genre name. This allows Genre grouping to be
// case-insensitive, which may be helpful in some libraries with different ways of
// formatting genres.
// Cache the hashCode for HashMap efficiency.
private val hashCode = name?.lowercase().hashCode()
override fun hashCode() = hashCode
override fun equals(other: Any?) =
other is Raw &&
when {
name != null && other.name != null -> name.equals(other.name, true)
name == null && other.name == null -> true
else -> false
}
}
}
/**

View file

@ -36,7 +36,7 @@ import org.oxycblt.auxio.BuildConfig
import org.oxycblt.auxio.music.*
import org.oxycblt.auxio.music.extractor.*
import org.oxycblt.auxio.music.library.Library
import org.oxycblt.auxio.music.library.RealSong
import org.oxycblt.auxio.music.library.RawSong
import org.oxycblt.auxio.util.logD
import org.oxycblt.auxio.util.logE
import org.oxycblt.auxio.util.logW
@ -362,14 +362,14 @@ private class RealIndexer : Indexer {
// Now start processing the queried song information in parallel. Songs that can't be
// received from the cache are consisted incomplete and pushed to a separate channel
// that will eventually be processed into completed raw songs.
val completeSongs = Channel<RealSong.Raw>(Channel.UNLIMITED)
val incompleteSongs = Channel<RealSong.Raw>(Channel.UNLIMITED)
val completeSongs = Channel<RawSong>(Channel.UNLIMITED)
val incompleteSongs = Channel<RawSong>(Channel.UNLIMITED)
val mediaStoreJob =
scope.async { mediaStoreExtractor.consume(cache, incompleteSongs, completeSongs) }
val metadataJob = scope.async { metadataExtractor.consume(incompleteSongs, completeSongs) }
// Await completed raw songs as they are processed.
val rawSongs = LinkedList<RealSong.Raw>()
val rawSongs = LinkedList<RawSong>()
for (rawSong in completeSongs) {
rawSongs.add(rawSong)
emitIndexing(Indexer.Indexing.Songs(rawSongs.size, total))

View file

@ -30,7 +30,6 @@ import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.cancel
import org.oxycblt.auxio.BuildConfig
import org.oxycblt.auxio.music.MusicRepository
import org.oxycblt.auxio.music.MusicSettings

View file

@ -1,30 +0,0 @@
/*
* Copyright (c) 2023 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.library
class LibraryTest {
fun library_common() {}
fun library_sparse() {}
fun library_multiArtist() {}
fun library_multiGenre() {}
fun library_musicBrainz() {}
}

View file

@ -25,7 +25,7 @@ import org.oxycblt.auxio.music.Music
import org.oxycblt.auxio.music.MusicMode
import org.oxycblt.auxio.music.metadata.Date
class RealMusicTest {
class RawMusicTest {
@Test
fun musicUid_auxio() {
val uid =
@ -53,23 +53,21 @@ class RealMusicTest {
@Test
fun albumRaw_equals_inconsistentCase() {
val a =
RealAlbum.Raw(
RawAlbum(
mediaStoreId = -1,
musicBrainzId = null,
name = "Paraglow",
sortName = null,
releaseType = null,
rawArtists =
listOf(RealArtist.Raw(name = "Parannoul"), RealArtist.Raw(name = "Asian Glow")))
rawArtists = listOf(RawArtist(name = "Parannoul"), RawArtist(name = "Asian Glow")))
val b =
RealAlbum.Raw(
RawAlbum(
mediaStoreId = -1,
musicBrainzId = null,
name = "paraglow",
sortName = null,
releaseType = null,
rawArtists =
listOf(RealArtist.Raw(name = "Parannoul"), RealArtist.Raw(name = "Asian glow")))
rawArtists = listOf(RawArtist(name = "Parannoul"), RawArtist(name = "Asian glow")))
assertTrue(a == b)
assertTrue(a.hashCode() == b.hashCode())
}
@ -77,21 +75,21 @@ class RealMusicTest {
@Test
fun albumRaw_equals_withMbids() {
val a =
RealAlbum.Raw(
RawAlbum(
mediaStoreId = -1,
musicBrainzId = UUID.fromString("c7b245c9-8099-32ea-af95-893acedde2cf"),
name = "Weezer",
sortName = "Blue Album",
releaseType = null,
rawArtists = listOf(RealArtist.Raw(name = "Weezer")))
rawArtists = listOf(RawArtist(name = "Weezer")))
val b =
RealAlbum.Raw(
RawAlbum(
mediaStoreId = -1,
musicBrainzId = UUID.fromString("923d5ba6-7eee-3bce-bcb2-c913b2bd69d4"),
name = "Weezer",
sortName = "Green Album",
releaseType = null,
rawArtists = listOf(RealArtist.Raw(name = "Weezer")))
rawArtists = listOf(RawArtist(name = "Weezer")))
assertTrue(a != b)
assertTrue(a.hashCode() != b.hashCode())
}
@ -99,21 +97,21 @@ class RealMusicTest {
@Test
fun albumRaw_equals_inconsistentMbids() {
val a =
RealAlbum.Raw(
RawAlbum(
mediaStoreId = -1,
musicBrainzId = UUID.fromString("c7b245c9-8099-32ea-af95-893acedde2cf"),
name = "Weezer",
sortName = "Blue Album",
releaseType = null,
rawArtists = listOf(RealArtist.Raw(name = "Weezer")))
rawArtists = listOf(RawArtist(name = "Weezer")))
val b =
RealAlbum.Raw(
RawAlbum(
mediaStoreId = -1,
musicBrainzId = null,
name = "Weezer",
sortName = "Green Album",
releaseType = null,
rawArtists = listOf(RealArtist.Raw(name = "Weezer")))
rawArtists = listOf(RawArtist(name = "Weezer")))
assertTrue(a != b)
assertTrue(a.hashCode() != b.hashCode())
}
@ -121,29 +119,29 @@ class RealMusicTest {
@Test
fun albumRaw_equals_withRealArtists() {
val a =
RealAlbum.Raw(
RawAlbum(
mediaStoreId = -1,
musicBrainzId = null,
name = "Album",
sortName = null,
releaseType = null,
rawArtists = listOf(RealArtist.Raw(name = "RealArtist A")))
rawArtists = listOf(RawArtist(name = "RealArtist A")))
val b =
RealAlbum.Raw(
RawAlbum(
mediaStoreId = -1,
musicBrainzId = null,
name = "Album",
sortName = null,
releaseType = null,
rawArtists = listOf(RealArtist.Raw(name = "RealArtist B")))
rawArtists = listOf(RawArtist(name = "RealArtist B")))
assertTrue(a != b)
assertTrue(a.hashCode() != b.hashCode())
}
@Test
fun artistRaw_equals_inconsistentCase() {
val a = RealArtist.Raw(musicBrainzId = null, name = "Parannoul")
val b = RealArtist.Raw(musicBrainzId = null, name = "parannoul")
val a = RawArtist(musicBrainzId = null, name = "Parannoul")
val b = RawArtist(musicBrainzId = null, name = "parannoul")
assertTrue(a == b)
assertTrue(a.hashCode() == b.hashCode())
}
@ -151,11 +149,11 @@ class RealMusicTest {
@Test
fun artistRaw_equals_withMbids() {
val a =
RealArtist.Raw(
RawArtist(
musicBrainzId = UUID.fromString("677325ef-d850-44bb-8258-0d69bbc0b3f7"),
name = "Artist")
val b =
RealArtist.Raw(
RawArtist(
musicBrainzId = UUID.fromString("6b625592-d88d-48c8-ac1a-c5b476d78bcc"),
name = "Artist")
assertTrue(a != b)
@ -165,50 +163,50 @@ class RealMusicTest {
@Test
fun artistRaw_equals_inconsistentMbids() {
val a =
RealArtist.Raw(
RawArtist(
musicBrainzId = UUID.fromString("677325ef-d850-44bb-8258-0d69bbc0b3f7"),
name = "Artist")
val b = RealArtist.Raw(musicBrainzId = null, name = "Artist")
val b = RawArtist(musicBrainzId = null, name = "Artist")
assertTrue(a != b)
assertTrue(a.hashCode() != b.hashCode())
}
@Test
fun artistRaw_equals_missingNames() {
val a = RealArtist.Raw(name = null)
val b = RealArtist.Raw(name = null)
val a = RawArtist(name = null)
val b = RawArtist(name = null)
assertTrue(a == b)
assertTrue(a.hashCode() == b.hashCode())
}
@Test
fun artistRaw_equals_inconsistentNames() {
val a = RealArtist.Raw(name = null)
val b = RealArtist.Raw(name = "Parannoul")
val a = RawArtist(name = null)
val b = RawArtist(name = "Parannoul")
assertTrue(a != b)
assertTrue(a.hashCode() != b.hashCode())
}
@Test
fun genreRaw_equals_inconsistentCase() {
val a = RealGenre.Raw("Future Garage")
val b = RealGenre.Raw("future garage")
val a = RawGenre("Future Garage")
val b = RawGenre("future garage")
assertTrue(a == b)
assertTrue(a.hashCode() == b.hashCode())
}
@Test
fun genreRaw_equals_missingNames() {
val a = RealGenre.Raw(name = null)
val b = RealGenre.Raw(name = null)
val a = RawGenre(name = null)
val b = RawGenre(name = null)
assertTrue(a == b)
assertTrue(a.hashCode() == b.hashCode())
}
@Test
fun genreRaw_equals_inconsistentNames() {
val a = RealGenre.Raw(name = null)
val b = RealGenre.Raw(name = "Future Garage")
val a = RawGenre(name = null)
val b = RawGenre(name = "Future Garage")
assertTrue(a != b)
assertTrue(a.hashCode() != b.hashCode())
}

View file

@ -15,7 +15,7 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.oxycblt.auxio.music.extractor
package org.oxycblt.auxio.music.metadata
import com.google.android.exoplayer2.metadata.Metadata
import com.google.android.exoplayer2.metadata.flac.PictureFrame
@ -26,7 +26,6 @@ import com.google.android.exoplayer2.metadata.vorbis.VorbisComment
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import org.oxycblt.auxio.music.metadata.TextTags
class TextTagsTest {
@Test