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.channels.Channel
import kotlinx.coroutines.yield import kotlinx.coroutines.yield
import org.oxycblt.auxio.music.MusicSettings 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.library.RealSong
import org.oxycblt.auxio.music.metadata.Date import org.oxycblt.auxio.music.metadata.Date
import org.oxycblt.auxio.music.parsing.parseId3v2PositionField import org.oxycblt.auxio.music.parsing.parseId3v2PositionField
@ -68,8 +69,8 @@ interface MediaStoreExtractor {
*/ */
suspend fun consume( suspend fun consume(
cache: MetadataCache?, cache: MetadataCache?,
incompleteSongs: Channel<RealSong.Raw>, incompleteSongs: Channel<RawSong>,
completeSongs: Channel<RealSong.Raw> completeSongs: Channel<RawSong>
) )
companion object { companion object {
@ -219,12 +220,12 @@ private abstract class RealMediaStoreExtractor(private val context: Context) : M
override suspend fun consume( override suspend fun consume(
cache: MetadataCache?, cache: MetadataCache?,
incompleteSongs: Channel<RealSong.Raw>, incompleteSongs: Channel<RawSong>,
completeSongs: Channel<RealSong.Raw> completeSongs: Channel<RawSong>
) { ) {
val cursor = requireNotNull(cursor) { "Must call query first before running consume" } val cursor = requireNotNull(cursor) { "Must call query first before running consume" }
while (cursor.moveToNext()) { while (cursor.moveToNext()) {
val rawSong = RealSong.Raw() val rawSong = RawSong()
populateFileData(cursor, rawSong) populateFileData(cursor, rawSong)
if (cache?.populate(rawSong) == true) { if (cache?.populate(rawSong) == true) {
completeSongs.send(rawSong) 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 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 * Populate a [RawSong] with the "File Data" of the given [MediaStore] [Cursor], which
* the data that cannot be cached. This includes any information not intrinsic to the file and * is the data that cannot be cached. This includes any information not intrinsic to the file
* instead dependent on the file-system, which could change without invalidating the cache due * and instead dependent on the file-system, which could change without invalidating the cache
* to volume additions or removals. * due to volume additions or removals.
* @param cursor The [Cursor] to read from. * @param cursor The [Cursor] to read from.
* @param raw The [RealSong.Raw] to populate. * @param rawSong The [RawSong] to populate.
* @see populateMetadata * @see populateMetadata
*/ */
protected open fun populateFileData(cursor: Cursor, raw: RealSong.Raw) { protected open fun populateFileData(cursor: Cursor, rawSong: RawSong) {
raw.mediaStoreId = cursor.getLong(idIndex) rawSong.mediaStoreId = cursor.getLong(idIndex)
raw.dateAdded = cursor.getLong(dateAddedIndex) rawSong.dateAdded = cursor.getLong(dateAddedIndex)
raw.dateModified = cursor.getLong(dateAddedIndex) rawSong.dateModified = cursor.getLong(dateAddedIndex)
// Try to use the DISPLAY_NAME column to obtain a (probably sane) file name // Try to use the DISPLAY_NAME column to obtain a (probably sane) file name
// from the android system. // from the android system.
raw.fileName = cursor.getStringOrNull(displayNameIndex) rawSong.fileName = cursor.getStringOrNull(displayNameIndex)
raw.extensionMimeType = cursor.getString(mimeTypeIndex) rawSong.extensionMimeType = cursor.getString(mimeTypeIndex)
raw.albumMediaStoreId = cursor.getLong(albumIdIndex) rawSong.albumMediaStoreId = cursor.getLong(albumIdIndex)
} }
/** /**
* Populate a [RealSong.Raw] with the Metadata of the given [MediaStore] [Cursor], which is the * Populate a [RawSong] with the Metadata of the given [MediaStore] [Cursor], which is
* data about a [RealSong.Raw] that can be cached. This includes any information intrinsic to * the data about a [RawSong] that can be cached. This includes any information
* the file or it's file format, such as music tags. * intrinsic to the file or it's file format, such as music tags.
* @param cursor The [Cursor] to read from. * @param cursor The [Cursor] to read from.
* @param raw The [RealSong.Raw] to populate. * @param rawSong The [RawSong] to populate.
* @see populateFileData * @see populateFileData
*/ */
protected open fun populateMetadata(cursor: Cursor, raw: RealSong.Raw) { protected open fun populateMetadata(cursor: Cursor, rawSong: RawSong) {
// Song title // Song title
raw.name = cursor.getString(titleIndex) rawSong.name = cursor.getString(titleIndex)
// Size (in bytes) // Size (in bytes)
raw.size = cursor.getLong(sizeIndex) rawSong.size = cursor.getLong(sizeIndex)
// Duration (in milliseconds) // 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 // 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. // 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. // 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 // 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 // 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 // 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) rawSong.albumName = cursor.getString(albumIndex)
// 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 columns default // 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 // to null if they are not present. If this column is such, null it so that
// it's easier to handle later. // it's easier to handle later.
val artist = cursor.getString(artistIndex) val artist = cursor.getString(artistIndex)
if (artist != MediaStore.UNKNOWN_STRING) { if (artist != MediaStore.UNKNOWN_STRING) {
raw.artistNames = listOf(artist) rawSong.artistNames = listOf(artist)
} }
// The album artist column is nullable and never has placeholder values. // 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 // 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 { companion object {
@ -398,8 +399,8 @@ private class Api21MediaStoreExtractor(context: Context) : RealMediaStoreExtract
return true return true
} }
override fun populateFileData(cursor: Cursor, raw: RealSong.Raw) { override fun populateFileData(cursor: Cursor, rawSong: RawSong) {
super.populateFileData(cursor, raw) super.populateFileData(cursor, rawSong)
val data = cursor.getString(dataIndex) 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 // 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 // present would completely break the scoped storage system. Fill it in with DATA
// if it's not available. // if it's not available.
if (raw.fileName == null) { if (rawSong.fileName == null) {
raw.fileName = data.substringAfterLast(File.separatorChar, "").ifEmpty { null } rawSong.fileName = data.substringAfterLast(File.separatorChar, "").ifEmpty { null }
} }
// Find the volume that transforms the DATA column into a relative path. This is // 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 volumePath = volume.directoryCompat ?: continue
val strippedPath = rawPath.removePrefix(volumePath) val strippedPath = rawPath.removePrefix(volumePath)
if (strippedPath != rawPath) { if (strippedPath != rawPath) {
raw.directory = Directory.from(volume, strippedPath) rawSong.directory = Directory.from(volume, strippedPath)
break break
} }
} }
} }
override fun populateMetadata(cursor: Cursor, raw: RealSong.Raw) { override fun populateMetadata(cursor: Cursor, rawSong: RawSong) {
super.populateMetadata(cursor, raw) super.populateMetadata(cursor, rawSong)
// See unpackTrackNo/unpackDiscNo for an explanation // See unpackTrackNo/unpackDiscNo for an explanation
// of how this column is set up. // of how this column is set up.
val rawTrack = cursor.getIntOrNull(trackIndex) val rawTrack = cursor.getIntOrNull(trackIndex)
if (rawTrack != null) { if (rawTrack != null) {
rawTrack.unpackTrackNo()?.let { raw.track = it } rawTrack.unpackTrackNo()?.let { rawSong.track = it }
rawTrack.unpackDiscNo()?.let { raw.disc = 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. * A [RealMediaStoreExtractor] that implements common behavior supported from API 29 onwards.
* @param context [Context] required to query the media database. * @param context [Context] required to query the media database.
* @param metadataCacheRepository [MetadataCacheRepository] implementation for cache optimizations.
* @author Alexander Capehart (OxygenCobalt) * @author Alexander Capehart (OxygenCobalt)
*/ */
@RequiresApi(Build.VERSION_CODES.Q) @RequiresApi(Build.VERSION_CODES.Q)
@ -485,15 +485,15 @@ private open class BaseApi29MediaStoreExtractor(context: Context) :
return true return true
} }
override fun populateFileData(cursor: Cursor, raw: RealSong.Raw) { override fun populateFileData(cursor: Cursor, rawSong: RawSong) {
super.populateFileData(cursor, raw) super.populateFileData(cursor, rawSong)
// Find the StorageVolume whose MediaStore name corresponds to this song. // Find the StorageVolume whose MediaStore name corresponds to this song.
// This is combined with the plain relative path column to create the directory. // This is combined with the plain relative path column to create the directory.
val volumeName = cursor.getString(volumeIndex) val volumeName = cursor.getString(volumeIndex)
val relativePath = cursor.getString(relativePathIndex) val relativePath = cursor.getString(relativePathIndex)
val volume = volumes.find { it.mediaStoreVolumeNameCompat == volumeName } val volume = volumes.find { it.mediaStoreVolumeNameCompat == volumeName }
if (volume != null) { 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 * API
* 29. * 29.
* @param context [Context] required to query the media database. * @param context [Context] required to query the media database.
* @param metadataCacheRepository [MetadataCacheRepository] implementation for cache functionality.
* @author Alexander Capehart (OxygenCobalt) * @author Alexander Capehart (OxygenCobalt)
*/ */
@RequiresApi(Build.VERSION_CODES.Q) @RequiresApi(Build.VERSION_CODES.Q)
@ -521,15 +520,15 @@ private open class Api29MediaStoreExtractor(context: Context) :
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 populateMetadata(cursor: Cursor, raw: RealSong.Raw) { override fun populateMetadata(cursor: Cursor, rawSong: RawSong) {
super.populateMetadata(cursor, raw) super.populateMetadata(cursor, rawSong)
// This extractor is volume-aware, but does not support the modern track columns. // This extractor is volume-aware, but does not support the modern track columns.
// Use the old column instead. See unpackTrackNo/unpackDiscNo for an explanation // Use the old column instead. See unpackTrackNo/unpackDiscNo for an explanation
// of how this column is set up. // of how this column is set up.
val rawTrack = cursor.getIntOrNull(trackIndex) val rawTrack = cursor.getIntOrNull(trackIndex)
if (rawTrack != null) { if (rawTrack != null) {
rawTrack.unpackTrackNo()?.let { raw.track = it } rawTrack.unpackTrackNo()?.let { rawSong.track = it }
rawTrack.unpackDiscNo()?.let { raw.disc = 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 * A [RealMediaStoreExtractor] that completes the music loading process in a way compatible from API
* 30 onwards. * 30 onwards.
* @param context [Context] required to query the media database. * @param context [Context] required to query the media database.
* @param metadataCacheRepository [MetadataCacheRepository] implementation for cache optimizations.
* @author Alexander Capehart (OxygenCobalt) * @author Alexander Capehart (OxygenCobalt)
*/ */
@RequiresApi(Build.VERSION_CODES.R) @RequiresApi(Build.VERSION_CODES.R)
@ -563,14 +561,14 @@ private class Api30MediaStoreExtractor(context: Context) : BaseApi29MediaStoreEx
MediaStore.Audio.AudioColumns.CD_TRACK_NUMBER, MediaStore.Audio.AudioColumns.CD_TRACK_NUMBER,
MediaStore.Audio.AudioColumns.DISC_NUMBER) MediaStore.Audio.AudioColumns.DISC_NUMBER)
override fun populateMetadata(cursor: Cursor, raw: RealSong.Raw) { override fun populateMetadata(cursor: Cursor, rawSong: RawSong) {
super.populateMetadata(cursor, raw) super.populateMetadata(cursor, rawSong)
// 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
// N is the number and T is the total. Parse the number while ignoring the // N is the number and T is the total. Parse the number while ignoring the
// total, as we have no use for it. // total, as we have no use for it.
cursor.getStringOrNull(trackIndex)?.parseId3v2PositionField()?.let { raw.track = it } cursor.getStringOrNull(trackIndex)?.parseId3v2PositionField()?.let { rawSong.track = it }
cursor.getStringOrNull(discIndex)?.parseId3v2PositionField()?.let { raw.disc = 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.TypeConverter
import androidx.room.TypeConverters import androidx.room.TypeConverters
import org.oxycblt.auxio.music.Song 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.library.RealSong
import org.oxycblt.auxio.music.metadata.Date import org.oxycblt.auxio.music.metadata.Date
import org.oxycblt.auxio.music.parsing.correctWhitespace import org.oxycblt.auxio.music.parsing.correctWhitespace
@ -41,15 +42,15 @@ import org.oxycblt.auxio.util.*
* @author Alexander Capehart (OxygenCobalt) * @author Alexander Capehart (OxygenCobalt)
*/ */
interface MetadataCache { 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 val invalidated: Boolean
/** /**
* Populate a [RealSong.Raw] from a cache entry, if it exists. * Populate a [RawSong] from a cache entry, if it exists.
* @param rawSong The [RealSong.Raw] to populate. * @param rawSong The [RawSong] to populate.
* @return true if a cache entry could be applied to [rawSong], false otherwise. * @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 { private class RealMetadataCache(cachedSongs: List<CachedSong>) : MetadataCache {
@ -60,7 +61,7 @@ private class RealMetadataCache(cachedSongs: List<CachedSong>) : MetadataCache {
} }
override var invalidated = false 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 // 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 // addition and modification timestamps. Technically the addition timestamp doesn't
@ -93,10 +94,10 @@ interface MetadataCacheRepository {
suspend fun readCache(): MetadataCache? 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. * @param rawSongs The [rawSongs] to write to the cache.
*/ */
suspend fun writeCache(rawSongs: List<RealSong.Raw>) suspend fun writeCache(rawSongs: List<RawSong>)
companion object { companion object {
/** /**
@ -124,7 +125,7 @@ private class RealMetadataCacheRepository(private val context: Context) : Metada
null null
} }
override suspend fun writeCache(rawSongs: List<RealSong.Raw>) { override suspend fun writeCache(rawSongs: List<RawSong>) {
try { try {
// Still write out whatever data was extracted. // Still write out whatever data was extracted.
cachedSongsDao.nukeSongs() cachedSongsDao.nukeSongs()
@ -185,52 +186,52 @@ private data class CachedSong(
* unstable and should only be used for accessing the audio file. * unstable and should only be used for accessing the audio file.
*/ */
@PrimaryKey var mediaStoreId: Long, @PrimaryKey var mediaStoreId: Long,
/** @see RealSong.Raw.dateAdded */ /** @see RawSong.dateAdded */
var dateAdded: Long, var dateAdded: Long,
/** The latest date the [Song]'s audio file was modified, as a unix epoch timestamp. */ /** The latest date the [Song]'s audio file was modified, as a unix epoch timestamp. */
var dateModified: Long, var dateModified: Long,
/** @see RealSong.Raw.size */ /** @see RawSong.size */
var size: Long? = null, var size: Long? = null,
/** @see RealSong.Raw */ /** @see RawSong */
var durationMs: Long, var durationMs: Long,
/** @see RealSong.Raw.musicBrainzId */ /** @see RawSong.musicBrainzId */
var musicBrainzId: String? = null, var musicBrainzId: String? = null,
/** @see RealSong.Raw.name */ /** @see RawSong.name */
var name: String, var name: String,
/** @see RealSong.Raw.sortName */ /** @see RawSong.sortName */
var sortName: String? = null, var sortName: String? = null,
/** @see RealSong.Raw.track */ /** @see RawSong.track */
var track: Int? = null, var track: Int? = null,
/** @see RealSong.Raw.name */ /** @see RawSong.name */
var disc: Int? = null, var disc: Int? = null,
/** @See RealSong.Raw.subtitle */ /** @See RawSong.subtitle */
var subtitle: String? = null, var subtitle: String? = null,
/** @see RealSong.Raw.date */ /** @see RawSong.date */
var date: Date? = null, var date: Date? = null,
/** @see RealSong.Raw.albumMusicBrainzId */ /** @see RawSong.albumMusicBrainzId */
var albumMusicBrainzId: String? = null, var albumMusicBrainzId: String? = null,
/** @see RealSong.Raw.albumName */ /** @see RawSong.albumName */
var albumName: String, var albumName: String,
/** @see RealSong.Raw.albumSortName */ /** @see RawSong.albumSortName */
var albumSortName: String? = null, var albumSortName: String? = null,
/** @see RealSong.Raw.releaseTypes */ /** @see RawSong.releaseTypes */
var releaseTypes: List<String> = listOf(), var releaseTypes: List<String> = listOf(),
/** @see RealSong.Raw.artistMusicBrainzIds */ /** @see RawSong.artistMusicBrainzIds */
var artistMusicBrainzIds: List<String> = listOf(), var artistMusicBrainzIds: List<String> = listOf(),
/** @see RealSong.Raw.artistNames */ /** @see RawSong.artistNames */
var artistNames: List<String> = listOf(), var artistNames: List<String> = listOf(),
/** @see RealSong.Raw.artistSortNames */ /** @see RawSong.artistSortNames */
var artistSortNames: List<String> = listOf(), var artistSortNames: List<String> = listOf(),
/** @see RealSong.Raw.albumArtistMusicBrainzIds */ /** @see RawSong.albumArtistMusicBrainzIds */
var albumArtistMusicBrainzIds: List<String> = listOf(), var albumArtistMusicBrainzIds: List<String> = listOf(),
/** @see RealSong.Raw.albumArtistNames */ /** @see RawSong.albumArtistNames */
var albumArtistNames: List<String> = listOf(), var albumArtistNames: List<String> = listOf(),
/** @see RealSong.Raw.albumArtistSortNames */ /** @see RawSong.albumArtistSortNames */
var albumArtistSortNames: List<String> = listOf(), var albumArtistSortNames: List<String> = listOf(),
/** @see RealSong.Raw.genreNames */ /** @see RawSong.genreNames */
var genreNames: List<String> = listOf() var genreNames: List<String> = listOf()
) { ) {
fun copyToRaw(rawSong: RealSong.Raw): CachedSong { fun copyToRaw(rawSong: RawSong): CachedSong {
rawSong.musicBrainzId = musicBrainzId rawSong.musicBrainzId = musicBrainzId
rawSong.name = name rawSong.name = name
rawSong.sortName = sortName rawSong.sortName = sortName
@ -275,7 +276,7 @@ private data class CachedSong(
companion object { companion object {
const val TABLE_NAME = "cached_songs" const val TABLE_NAME = "cached_songs"
fun fromRaw(rawSong: RealSong.Raw) = fun fromRaw(rawSong: RawSong) =
CachedSong( CachedSong(
mediaStoreId = mediaStoreId =
requireNotNull(rawSong.mediaStoreId) { "Invalid raw: No MediaStore ID" }, 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 com.google.android.exoplayer2.MetadataRetriever
import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.yield import kotlinx.coroutines.yield
import org.oxycblt.auxio.music.library.RawSong
import org.oxycblt.auxio.music.library.RealSong import org.oxycblt.auxio.music.library.RealSong
import org.oxycblt.auxio.music.metadata.Date import org.oxycblt.auxio.music.metadata.Date
import org.oxycblt.auxio.music.metadata.TextTags 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. // producing similar throughput's to other kinds of manual metadata extraction.
private val taskPool: Array<Task?> = arrayOfNulls(TASK_CAPACITY) private val taskPool: Array<Task?> = arrayOfNulls(TASK_CAPACITY)
suspend fun consume( suspend fun consume(incompleteSongs: Channel<RawSong>, completeSongs: Channel<RawSong>) {
incompleteSongs: Channel<RealSong.Raw>,
completeSongs: Channel<RealSong.Raw>
) {
spin@ while (true) { spin@ while (true) {
// Spin until there is an open slot we can insert a task in. // Spin until there is an open slot we can insert a task in.
for (i in taskPool.indices) { 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 context [Context] required to open the audio file.
* @param raw [RealSong.Raw] to process. * @param rawSong [RawSong] to process.
* @author Alexander Capehart (OxygenCobalt) * @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 // 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 // (highly fallible) extraction process will not bubble up to Indexer when a
// listener is used, instead crashing the app entirely. // listener is used, instead crashing the app entirely.
@ -110,15 +108,13 @@ private class Task(context: Context, private val raw: RealSong.Raw) {
MetadataRetriever.retrieveMetadata( MetadataRetriever.retrieveMetadata(
context, context,
MediaItem.fromUri( MediaItem.fromUri(
requireNotNull(raw.mediaStoreId) { "Invalid raw: No id" }.toAudioUri())) requireNotNull(rawSong.mediaStoreId) { "Invalid raw: No id" }.toAudioUri()))
init {}
/** /**
* Try to get a completed song from this [Task], if it has finished processing. * 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) { if (!future.isDone) {
// Not done yet, nothing to do. // Not done yet, nothing to do.
return null return null
@ -128,13 +124,13 @@ private class Task(context: Context, private val raw: RealSong.Raw) {
try { try {
future.get()[0].getFormat(0) future.get()[0].getFormat(0)
} catch (e: Exception) { } catch (e: Exception) {
logW("Unable to extract metadata for ${raw.name}") logW("Unable to extract metadata for ${rawSong.name}")
logW(e.stackTraceToString()) logW(e.stackTraceToString())
null null
} }
if (format == null) { if (format == null) {
logD("Nothing could be extracted for ${raw.name}") logD("Nothing could be extracted for ${rawSong.name}")
return raw return rawSong
} }
val metadata = format.metadata val metadata = format.metadata
@ -143,29 +139,29 @@ private class Task(context: Context, private val raw: RealSong.Raw) {
populateWithId3v2(textTags.id3v2) populateWithId3v2(textTags.id3v2)
populateWithVorbis(textTags.vorbis) populateWithVorbis(textTags.vorbis)
} else { } 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 * @param textFrames A mapping between ID3v2 Text Identification Frame IDs and one or more
* values. * values.
*/ */
private fun populateWithId3v2(textFrames: Map<String, List<String>>) { private fun populateWithId3v2(textFrames: Map<String, List<String>>) {
// Song // Song
textFrames["TXXX:musicbrainz release track id"]?.let { raw.musicBrainzId = it.first() } textFrames["TXXX:musicbrainz release track id"]?.let { rawSong.musicBrainzId = it.first() }
textFrames["TIT2"]?.let { raw.name = it.first() } textFrames["TIT2"]?.let { rawSong.name = it.first() }
textFrames["TSOT"]?.let { raw.sortName = it.first() } textFrames["TSOT"]?.let { rawSong.sortName = it.first() }
// Track. // 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. // Disc and it's subtitle name.
textFrames["TPOS"]?.run { first().parseId3v2PositionField() }?.let { raw.disc = it } textFrames["TPOS"]?.run { first().parseId3v2PositionField() }?.let { rawSong.disc = it }
textFrames["TSST"]?.let { raw.subtitle = it.first() } textFrames["TSST"]?.let { rawSong.subtitle = it.first() }
// Dates are somewhat complicated, as not only did their semantics change from a flat year // 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 // 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["TDRC"]?.run { Date.from(first()) }
?: textFrames["TDRL"]?.run { Date.from(first()) } ?: textFrames["TDRL"]?.run { Date.from(first()) }
?: parseId3v23Date(textFrames)) ?: parseId3v23Date(textFrames))
?.let { raw.date = it } ?.let { rawSong.date = it }
// Album // Album
textFrames["TXXX:musicbrainz album id"]?.let { raw.albumMusicBrainzId = it.first() } textFrames["TXXX:musicbrainz album id"]?.let { rawSong.albumMusicBrainzId = it.first() }
textFrames["TALB"]?.let { raw.albumName = it.first() } textFrames["TALB"]?.let { rawSong.albumName = it.first() }
textFrames["TSOA"]?.let { raw.albumSortName = it.first() } textFrames["TSOA"]?.let { rawSong.albumSortName = it.first() }
(textFrames["TXXX:musicbrainz album type"] ?: textFrames["GRP1"])?.let { (textFrames["TXXX:musicbrainz album type"] ?: textFrames["GRP1"])?.let {
raw.releaseTypes = it rawSong.releaseTypes = it
} }
// Artist // Artist
textFrames["TXXX:musicbrainz artist id"]?.let { raw.artistMusicBrainzIds = it } textFrames["TXXX:musicbrainz artist id"]?.let { rawSong.artistMusicBrainzIds = it }
(textFrames["TXXX:artists"] ?: textFrames["TPE1"])?.let { raw.artistNames = it } (textFrames["TXXX:artists"] ?: textFrames["TPE1"])?.let { rawSong.artistNames = it }
(textFrames["TXXX:artists_sort"] ?: textFrames["TSOP"])?.let { raw.artistSortNames = it } (textFrames["TXXX:artists_sort"] ?: textFrames["TSOP"])?.let {
rawSong.artistSortNames = it
}
// Album artist // Album artist
textFrames["TXXX:musicbrainz album artist id"]?.let { raw.albumArtistMusicBrainzIds = it } textFrames["TXXX:musicbrainz album artist id"]?.let {
(textFrames["TXXX:albumartists"] ?: textFrames["TPE2"])?.let { raw.albumArtistNames = it } rawSong.albumArtistMusicBrainzIds = it
}
(textFrames["TXXX:albumartists"] ?: textFrames["TPE2"])?.let {
rawSong.albumArtistNames = it
}
(textFrames["TXXX:albumartists_sort"] ?: textFrames["TSO2"])?.let { (textFrames["TXXX:albumartists_sort"] ?: textFrames["TSO2"])?.let {
raw.albumArtistSortNames = it rawSong.albumArtistSortNames = it
} }
// Genre // 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. * @param comments A mapping between vorbis comment names and one or more vorbis comment values.
*/ */
private fun populateWithVorbis(comments: Map<String, List<String>>) { private fun populateWithVorbis(comments: Map<String, List<String>>) {
// Song // Song
comments["musicbrainz_releasetrackid"]?.let { raw.musicBrainzId = it.first() } comments["musicbrainz_releasetrackid"]?.let { rawSong.musicBrainzId = it.first() }
comments["title"]?.let { raw.name = it.first() } comments["title"]?.let { rawSong.name = it.first() }
comments["titlesort"]?.let { raw.sortName = it.first() } comments["titlesort"]?.let { rawSong.sortName = it.first() }
// Track. // Track.
parseVorbisPositionField( parseVorbisPositionField(
comments["tracknumber"]?.first(), comments["tracknumber"]?.first(),
(comments["totaltracks"] ?: comments["tracktotal"] ?: comments["trackc"])?.first()) (comments["totaltracks"] ?: comments["tracktotal"] ?: comments["trackc"])?.first())
?.let { raw.track = it } ?.let { rawSong.track = it }
// Disc and it's subtitle name. // Disc and it's subtitle name.
parseVorbisPositionField( parseVorbisPositionField(
comments["discnumber"]?.first(), comments["discnumber"]?.first(),
(comments["totaldiscs"] ?: comments["disctotal"] ?: comments["discc"])?.first()) (comments["totaldiscs"] ?: comments["disctotal"] ?: comments["discc"])?.first())
?.let { raw.disc = it } ?.let { rawSong.disc = it }
comments["discsubtitle"]?.let { raw.subtitle = it.first() } comments["discsubtitle"]?.let { rawSong.subtitle = it.first() }
// Vorbis dates are less complicated, but there are still several types // Vorbis dates are less complicated, but there are still several types
// Our hierarchy for dates is as such: // 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["originaldate"]?.run { Date.from(first()) }
?: comments["date"]?.run { Date.from(first()) } ?: comments["date"]?.run { Date.from(first()) }
?: comments["year"]?.run { Date.from(first()) }) ?: comments["year"]?.run { Date.from(first()) })
?.let { raw.date = it } ?.let { rawSong.date = it }
// Album // Album
comments["musicbrainz_albumid"]?.let { raw.albumMusicBrainzId = it.first() } comments["musicbrainz_albumid"]?.let { rawSong.albumMusicBrainzId = it.first() }
comments["album"]?.let { raw.albumName = it.first() } comments["album"]?.let { rawSong.albumName = it.first() }
comments["albumsort"]?.let { raw.albumSortName = it.first() } comments["albumsort"]?.let { rawSong.albumSortName = it.first() }
comments["releasetype"]?.let { raw.releaseTypes = it } comments["releasetype"]?.let { rawSong.releaseTypes = it }
// Artist // Artist
comments["musicbrainz_artistid"]?.let { raw.artistMusicBrainzIds = it } comments["musicbrainz_artistid"]?.let { rawSong.artistMusicBrainzIds = it }
(comments["artists"] ?: comments["artist"])?.let { raw.artistNames = it } (comments["artists"] ?: comments["artist"])?.let { rawSong.artistNames = it }
(comments["artists_sort"] ?: comments["artistsort"])?.let { raw.artistSortNames = it } (comments["artists_sort"] ?: comments["artistsort"])?.let { rawSong.artistSortNames = it }
// Album artist // Album artist
comments["musicbrainz_albumartistid"]?.let { raw.albumArtistMusicBrainzIds = it } comments["musicbrainz_albumartistid"]?.let { rawSong.albumArtistMusicBrainzIds = it }
(comments["albumartists"] ?: comments["albumartist"])?.let { raw.albumArtistNames = it } (comments["albumartists"] ?: comments["albumartist"])?.let { rawSong.albumArtistNames = it }
(comments["albumartists_sort"] ?: comments["albumartistsort"])?.let { (comments["albumartists_sort"] ?: comments["albumartistsort"])?.let {
raw.albumArtistSortNames = it rawSong.albumArtistSortNames = it
} }
// Genre // Genre
comments["genre"]?.let { raw.genreNames = it } comments["genre"]?.let { rawSong.genreNames = it }
} }
} }

View file

@ -77,15 +77,15 @@ interface Library {
companion object { companion object {
/** /**
* Create a library-backed instance of [Library]. * 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. * @param settings [MusicSettings] required.
*/ */
fun from(rawSongs: List<RealSong.Raw>, settings: MusicSettings): Library = fun from(rawSongs: List<RawSong>, settings: MusicSettings): Library =
RealLibrary(rawSongs, settings) 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 songs = buildSongs(rawSongs, settings)
override val albums = buildAlbums(songs) override val albums = buildAlbums(songs)
override val artists = buildArtists(songs, albums) 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]. * Build a list [RealSong]s from the given [RawSong].
* @param rawSongs The [RealSong.Raw]s to build the [RealSong]s from. * @param rawSongs The [RawSong]s to build the [RealSong]s from.
* @param settings [MusicSettings] required to build [RealSong]s. * @param settings [MusicSettings] required to build [RealSong]s.
* @return A sorted list of [RealSong]s derived from the [RealSong.Raw] that should be suitable * @return A sorted list of [RealSong]s derived from the [RawSong] that should be suitable for
* for grouping. * 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()) 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> { 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, // Add every raw artist credited to each Song/Album to the grouping. This way,
// different multi-artist combinations are not treated as different artists. // 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 (song in songs) {
for (rawArtist in song.rawArtists) { 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> { private fun buildGenres(songs: List<RealSong>): List<RealGenre> {
// Add every raw genre credited to each Song to the grouping. This way, // Add every raw genre credited to each Song to the grouping. This way,
// different multi-genre combinations are not treated as different genres. // 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 (song in songs) {
for (rawGenre in song.rawGenres) { for (rawGenre in song.rawGenres) {
songsByGenre.getOrPut(rawGenre) { mutableListOf() }.add(song) 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.security.MessageDigest
import java.text.CollationKey import java.text.CollationKey
import java.text.Collator import java.text.Collator
import java.util.UUID
import kotlin.math.max import kotlin.math.max
import org.oxycblt.auxio.R import org.oxycblt.auxio.R
import org.oxycblt.auxio.music.Album 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.metadata.ReleaseType
import org.oxycblt.auxio.music.parsing.parseId3GenreNames import org.oxycblt.auxio.music.parsing.parseId3GenreNames
import org.oxycblt.auxio.music.parsing.parseMultiValue import org.oxycblt.auxio.music.parsing.parseMultiValue
import org.oxycblt.auxio.music.storage.Directory
import org.oxycblt.auxio.music.storage.MimeType import org.oxycblt.auxio.music.storage.MimeType
import org.oxycblt.auxio.music.storage.Path import org.oxycblt.auxio.music.storage.Path
import org.oxycblt.auxio.music.storage.toAudioUri 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.toUuidOrNull
import org.oxycblt.auxio.util.unlikelyToBeNull import org.oxycblt.auxio.util.unlikelyToBeNull
// TODO: Split off raw music and real music
/** /**
* Library-backed implementation of [RealSong]. * 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. * @param musicSettings [MusicSettings] to perform further user-configured parsing.
* @author Alexander Capehart (OxygenCobalt) * @author Alexander Capehart (OxygenCobalt)
*/ */
class RealSong(raw: Raw, musicSettings: MusicSettings) : Song { class RealSong(rawSong: RawSong, musicSettings: MusicSettings) : Song {
override val uid = override val uid =
// Attempt to use a MusicBrainz ID first before falling back to a hashed 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) { ?: Music.UID.auxio(MusicMode.SONGS) {
// Song UIDs are based on the raw data without parsing so that they remain // 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 // consistent across music setting changes. Parents are not held up to the
// same standard since grouping is already inherently linked to settings. // same standard since grouping is already inherently linked to settings.
update(raw.name) update(rawSong.name)
update(raw.albumName) update(rawSong.albumName)
update(raw.date) update(rawSong.date)
update(raw.track) update(rawSong.track)
update(raw.disc) update(rawSong.disc)
update(raw.artistNames) update(rawSong.artistNames)
update(raw.albumArtistNames) update(rawSong.albumArtistNames)
} }
override val rawName = requireNotNull(raw.name) { "Invalid raw: No title" } override val rawName = requireNotNull(rawSong.name) { "Invalid raw: No title" }
override val rawSortName = raw.sortName override val rawSortName = rawSong.sortName
override val collationKey = makeCollationKey(this) override val collationKey = makeCollationKey(this)
override fun resolveName(context: Context) = rawName override fun resolveName(context: Context) = rawName
override val track = raw.track override val track = rawSong.track
override val disc = raw.disc?.let { Disc(it, raw.subtitle) } override val disc = rawSong.disc?.let { Disc(it, rawSong.subtitle) }
override val date = raw.date override val date = rawSong.date
override val uri = requireNotNull(raw.mediaStoreId) { "Invalid raw: No id" }.toAudioUri() override val uri = requireNotNull(rawSong.mediaStoreId) { "Invalid raw: No id" }.toAudioUri()
override val path = override val path =
Path( Path(
name = requireNotNull(raw.fileName) { "Invalid raw: No display name" }, name = requireNotNull(rawSong.fileName) { "Invalid raw: No display name" },
parent = requireNotNull(raw.directory) { "Invalid raw: No parent directory" }) parent = requireNotNull(rawSong.directory) { "Invalid raw: No parent directory" })
override val mimeType = override val mimeType =
MimeType( MimeType(
fromExtension = requireNotNull(raw.extensionMimeType) { "Invalid raw: No mime type" }, fromExtension =
requireNotNull(rawSong.extensionMimeType) { "Invalid raw: No mime type" },
fromFormat = null) fromFormat = null)
override val size = requireNotNull(raw.size) { "Invalid raw: No size" } override val size = requireNotNull(rawSong.size) { "Invalid raw: No size" }
override val durationMs = requireNotNull(raw.durationMs) { "Invalid raw: No duration" } override val durationMs = requireNotNull(rawSong.durationMs) { "Invalid raw: No duration" }
override val dateAdded = requireNotNull(raw.dateAdded) { "Invalid raw: No date added" } override val dateAdded = requireNotNull(rawSong.dateAdded) { "Invalid raw: No date added" }
private var _album: RealAlbum? = null private var _album: RealAlbum? = null
override val album: Album override val album: Album
get() = unlikelyToBeNull(_album) get() = unlikelyToBeNull(_album)
@ -100,24 +97,24 @@ class RealSong(raw: Raw, musicSettings: MusicSettings) : Song {
override fun hashCode() = uid.hashCode() override fun hashCode() = uid.hashCode()
override fun equals(other: Any?) = other is Song && uid == other.uid override fun equals(other: Any?) = other is Song && uid == other.uid
private val artistMusicBrainzIds = raw.artistMusicBrainzIds.parseMultiValue(musicSettings) private val artistMusicBrainzIds = rawSong.artistMusicBrainzIds.parseMultiValue(musicSettings)
private val artistNames = raw.artistNames.parseMultiValue(musicSettings) private val artistNames = rawSong.artistNames.parseMultiValue(musicSettings)
private val artistSortNames = raw.artistSortNames.parseMultiValue(musicSettings) private val artistSortNames = rawSong.artistSortNames.parseMultiValue(musicSettings)
private val rawIndividualArtists = private val rawIndividualArtists =
artistNames.mapIndexed { i, name -> artistNames.mapIndexed { i, name ->
RealArtist.Raw( RawArtist(
artistMusicBrainzIds.getOrNull(i)?.toUuidOrNull(), artistMusicBrainzIds.getOrNull(i)?.toUuidOrNull(),
name, name,
artistSortNames.getOrNull(i)) artistSortNames.getOrNull(i))
} }
private val albumArtistMusicBrainzIds = private val albumArtistMusicBrainzIds =
raw.albumArtistMusicBrainzIds.parseMultiValue(musicSettings) rawSong.albumArtistMusicBrainzIds.parseMultiValue(musicSettings)
private val albumArtistNames = raw.albumArtistNames.parseMultiValue(musicSettings) private val albumArtistNames = rawSong.albumArtistNames.parseMultiValue(musicSettings)
private val albumArtistSortNames = raw.albumArtistSortNames.parseMultiValue(musicSettings) private val albumArtistSortNames = rawSong.albumArtistSortNames.parseMultiValue(musicSettings)
private val rawAlbumArtists = private val rawAlbumArtists =
albumArtistNames.mapIndexed { i, name -> albumArtistNames.mapIndexed { i, name ->
RealArtist.Raw( RawArtist(
albumArtistMusicBrainzIds.getOrNull(i)?.toUuidOrNull(), albumArtistMusicBrainzIds.getOrNull(i)?.toUuidOrNull(),
name, name,
albumArtistSortNames.getOrNull(i)) albumArtistSortNames.getOrNull(i))
@ -145,39 +142,38 @@ class RealSong(raw: Raw, musicSettings: MusicSettings) : Song {
override fun resolveGenreContents(context: Context) = resolveNames(context, genres) override fun resolveGenreContents(context: Context) = resolveNames(context, genres)
/** /**
* The [RealAlbum.Raw] instances collated by the [RealSong]. This can be used to group * The [RawAlbum] instances collated by the [RealSong]. This can be used to group [RealSong]s
* [RealSong]s into an [RealAlbum]. * into an [RealAlbum].
*/ */
val rawAlbum = val rawAlbum =
RealAlbum.Raw( RawAlbum(
mediaStoreId = requireNotNull(raw.albumMediaStoreId) { "Invalid raw: No album id" }, mediaStoreId = requireNotNull(rawSong.albumMediaStoreId) { "Invalid raw: No album id" },
musicBrainzId = raw.albumMusicBrainzId?.toUuidOrNull(), musicBrainzId = rawSong.albumMusicBrainzId?.toUuidOrNull(),
name = requireNotNull(raw.albumName) { "Invalid raw: No album name" }, name = requireNotNull(rawSong.albumName) { "Invalid raw: No album name" },
sortName = raw.albumSortName, sortName = rawSong.albumSortName,
releaseType = ReleaseType.parse(raw.releaseTypes.parseMultiValue(musicSettings)), releaseType = ReleaseType.parse(rawSong.releaseTypes.parseMultiValue(musicSettings)),
rawArtists = rawArtists =
rawAlbumArtists rawAlbumArtists
.ifEmpty { rawIndividualArtists } .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 * The [RawArtist] instances collated by the [RealSong]. The artists of the song take priority,
* priority, followed by the album artists. If there are no artists, this field will be a single * followed by the album artists. If there are no artists, this field will be a single "unknown"
* "unknown" [RealArtist.Raw]. This can be used to group up [RealSong]s into an [RealArtist]. * [RawArtist]. This can be used to group up [RealSong]s into an [RealArtist].
*/ */
val rawArtists = 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 * The [RawGenre] instances collated by the [RealSong]. This can be used to group up [RealSong]s
* [RealSong]s into a [RealGenre]. ID3v2 Genre names are automatically converted to their * into a [RealGenre]. ID3v2 Genre names are automatically converted to their resolved names.
* resolved names.
*/ */
val rawGenres = val rawGenres =
raw.genreNames rawSong.genreNames
.parseId3GenreNames(musicSettings) .parseId3GenreNames(musicSettings)
.map { RealGenre.Raw(it) } .map { RawGenre(it) }
.ifEmpty { listOf(RealGenre.Raw()) } .ifEmpty { listOf(RawGenre()) }
/** /**
* Links this [RealSong] with a parent [RealAlbum]. * Links this [RealSong] with a parent [RealAlbum].
@ -231,95 +227,34 @@ class RealSong(raw: Raw, musicSettings: MusicSettings) : Song {
} }
return this 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]. * 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 * @param songs The [RealSong]s that are a part of this [RealAlbum]. These items will be linked to
* this [RealAlbum]. * this [RealAlbum].
* @author Alexander Capehart (OxygenCobalt) * @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 = override val uid =
// Attempt to use a MusicBrainz ID first before falling back to a hashed 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) { ?: Music.UID.auxio(MusicMode.ALBUMS) {
// Hash based on only names despite the presence of a date to increase stability. // 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 // 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. // the exact same name, but if there is, I would love to know.
update(raw.name) update(rawAlbum.name)
update(raw.rawArtists.map { it.name }) update(rawAlbum.rawArtists.map { it.name })
} }
override val rawName = raw.name override val rawName = rawAlbum.name
override val rawSortName = raw.sortName override val rawSortName = rawAlbum.sortName
override val collationKey = makeCollationKey(this) override val collationKey = makeCollationKey(this)
override fun resolveName(context: Context) = rawName override fun resolveName(context: Context) = rawName
override val dates = Date.Range.from(songs.mapNotNull { it.date }) override val dates = Date.Range.from(songs.mapNotNull { it.date })
override val releaseType = raw.releaseType ?: ReleaseType.Album(null) override val releaseType = rawAlbum.releaseType ?: ReleaseType.Album(null)
override val coverUri = raw.mediaStoreId.toCoverUri() override val coverUri = rawAlbum.mediaStoreId.toCoverUri()
override val durationMs: Long override val durationMs: Long
override val dateAdded: 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 * The [RawArtist] instances collated by the [RealAlbum]. The album artists of the song take
* take priority, followed by the artists. If there are no artists, this field will be a single * 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]. * "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]. * Links this [RealAlbum] with a parent [RealArtist].
@ -393,65 +328,23 @@ class RealAlbum(val raw: Raw, override val songs: List<RealSong>) : Album {
} }
return this 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]. * 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] * @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. * , either through artist or album artist tags. Providing [RealSong]s to the artist is optional.
* These instances will be linked to this [RealArtist]. * These instances will be linked to this [RealArtist].
* @author Alexander Capehart (OxygenCobalt) * @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 = override val uid =
// Attempt to use a MusicBrainz ID first before falling back to a hashed UID. // Attempt to use a MusicBrainz ID first before falling back to a hashed UID.
raw.musicBrainzId?.let { Music.UID.musicBrainz(MusicMode.ARTISTS, it) } rawArtist.musicBrainzId?.let { Music.UID.musicBrainz(MusicMode.ARTISTS, it) }
?: Music.UID.auxio(MusicMode.ARTISTS) { update(raw.name) } ?: Music.UID.auxio(MusicMode.ARTISTS) { update(rawArtist.name) }
override val rawName = raw.name override val rawName = rawArtist.name
override val rawSortName = raw.sortName override val rawSortName = rawArtist.sortName
override val collationKey = makeCollationKey(this) override val collationKey = makeCollationKey(this)
override fun resolveName(context: Context) = rawName ?: context.getString(R.string.def_artist) override fun resolveName(context: Context) = rawName ?: context.getString(R.string.def_artist)
override val songs: List<Song> 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 * Returns the original position of this [RealArtist]'s [RawArtist] within the given [RawArtist]
* [RealArtist.Raw] list. This can be used to create a consistent ordering within child * list. This can be used to create a consistent ordering within child [RealArtist] lists based
* [RealArtist] lists based on the original tag order. * on the original tag order.
* @param rawArtists The [RealArtist.Raw] instances to check. It is assumed that this * @param rawArtists The [RawArtist] instances to check. It is assumed that this [RealArtist]'s
* [RealArtist]'s [RealArtist.Raw] will be within the list. * [RawArtist] will be within the list.
* @return The index of the [RealArtist]'s [RealArtist.Raw] 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. * 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) } } .sortedByDescending { genre -> songs.count { it.genres.contains(genre) } }
return this 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]. * Library-backed implementation of [RealGenre].
* @author Alexander Capehart (OxygenCobalt) * @author Alexander Capehart (OxygenCobalt)
*/ */
class RealGenre(private val raw: Raw, override val songs: List<RealSong>) : Genre { class RealGenre(private val rawGenre: RawGenre, override val songs: List<RealSong>) : Genre {
override val uid = Music.UID.auxio(MusicMode.GENRES) { update(raw.name) } override val uid = Music.UID.auxio(MusicMode.GENRES) { update(rawGenre.name) }
override val rawName = raw.name override val rawName = rawGenre.name
override val rawSortName = rawName override val rawSortName = rawName
override val collationKey = makeCollationKey(this) override val collationKey = makeCollationKey(this)
override fun resolveName(context: Context) = rawName ?: context.getString(R.string.def_genre) 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 * Returns the original position of this [RealGenre]'s [RawGenre] within the given [RawGenre]
* [RealGenre.Raw] list. This can be used to create a consistent ordering within child * list. This can be used to create a consistent ordering within child [RealGenre] lists based
* [RealGenre] lists based on the original tag order. * on the original tag order.
* @param rawGenres The [RealGenre.Raw] instances to check. It is assumed that this [RealGenre] * @param rawGenres The [RawGenre] instances to check. It is assumed that this [RealGenre] 's
* 's [RealGenre.Raw] will be within the list. * [RawGenre] will be within the list.
* @return The index of the [RealGenre]'s [RealGenre.Raw] 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. * 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" } check(songs.isNotEmpty()) { "Malformed genre: Empty" }
return this 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.*
import org.oxycblt.auxio.music.extractor.* import org.oxycblt.auxio.music.extractor.*
import org.oxycblt.auxio.music.library.Library 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.logD
import org.oxycblt.auxio.util.logE import org.oxycblt.auxio.util.logE
import org.oxycblt.auxio.util.logW 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 // 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 // received from the cache are consisted incomplete and pushed to a separate channel
// that will eventually be processed into completed raw songs. // that will eventually be processed into completed raw songs.
val completeSongs = Channel<RealSong.Raw>(Channel.UNLIMITED) val completeSongs = Channel<RawSong>(Channel.UNLIMITED)
val incompleteSongs = Channel<RealSong.Raw>(Channel.UNLIMITED) val incompleteSongs = Channel<RawSong>(Channel.UNLIMITED)
val mediaStoreJob = val mediaStoreJob =
scope.async { mediaStoreExtractor.consume(cache, incompleteSongs, completeSongs) } scope.async { mediaStoreExtractor.consume(cache, incompleteSongs, completeSongs) }
val metadataJob = scope.async { metadataExtractor.consume(incompleteSongs, completeSongs) } val metadataJob = scope.async { metadataExtractor.consume(incompleteSongs, completeSongs) }
// Await completed raw songs as they are processed. // Await completed raw songs as they are processed.
val rawSongs = LinkedList<RealSong.Raw>() val rawSongs = LinkedList<RawSong>()
for (rawSong in completeSongs) { for (rawSong in completeSongs) {
rawSongs.add(rawSong) rawSongs.add(rawSong)
emitIndexing(Indexer.Indexing.Songs(rawSongs.size, total)) 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.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.cancel
import org.oxycblt.auxio.BuildConfig import org.oxycblt.auxio.BuildConfig
import org.oxycblt.auxio.music.MusicRepository import org.oxycblt.auxio.music.MusicRepository
import org.oxycblt.auxio.music.MusicSettings 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.MusicMode
import org.oxycblt.auxio.music.metadata.Date import org.oxycblt.auxio.music.metadata.Date
class RealMusicTest { class RawMusicTest {
@Test @Test
fun musicUid_auxio() { fun musicUid_auxio() {
val uid = val uid =
@ -53,23 +53,21 @@ class RealMusicTest {
@Test @Test
fun albumRaw_equals_inconsistentCase() { fun albumRaw_equals_inconsistentCase() {
val a = val a =
RealAlbum.Raw( RawAlbum(
mediaStoreId = -1, mediaStoreId = -1,
musicBrainzId = null, musicBrainzId = null,
name = "Paraglow", name = "Paraglow",
sortName = null, sortName = null,
releaseType = null, releaseType = null,
rawArtists = rawArtists = listOf(RawArtist(name = "Parannoul"), RawArtist(name = "Asian Glow")))
listOf(RealArtist.Raw(name = "Parannoul"), RealArtist.Raw(name = "Asian Glow")))
val b = val b =
RealAlbum.Raw( RawAlbum(
mediaStoreId = -1, mediaStoreId = -1,
musicBrainzId = null, musicBrainzId = null,
name = "paraglow", name = "paraglow",
sortName = null, sortName = null,
releaseType = null, releaseType = null,
rawArtists = rawArtists = listOf(RawArtist(name = "Parannoul"), RawArtist(name = "Asian glow")))
listOf(RealArtist.Raw(name = "Parannoul"), RealArtist.Raw(name = "Asian glow")))
assertTrue(a == b) assertTrue(a == b)
assertTrue(a.hashCode() == b.hashCode()) assertTrue(a.hashCode() == b.hashCode())
} }
@ -77,21 +75,21 @@ class RealMusicTest {
@Test @Test
fun albumRaw_equals_withMbids() { fun albumRaw_equals_withMbids() {
val a = val a =
RealAlbum.Raw( RawAlbum(
mediaStoreId = -1, mediaStoreId = -1,
musicBrainzId = UUID.fromString("c7b245c9-8099-32ea-af95-893acedde2cf"), musicBrainzId = UUID.fromString("c7b245c9-8099-32ea-af95-893acedde2cf"),
name = "Weezer", name = "Weezer",
sortName = "Blue Album", sortName = "Blue Album",
releaseType = null, releaseType = null,
rawArtists = listOf(RealArtist.Raw(name = "Weezer"))) rawArtists = listOf(RawArtist(name = "Weezer")))
val b = val b =
RealAlbum.Raw( RawAlbum(
mediaStoreId = -1, mediaStoreId = -1,
musicBrainzId = UUID.fromString("923d5ba6-7eee-3bce-bcb2-c913b2bd69d4"), musicBrainzId = UUID.fromString("923d5ba6-7eee-3bce-bcb2-c913b2bd69d4"),
name = "Weezer", name = "Weezer",
sortName = "Green Album", sortName = "Green Album",
releaseType = null, releaseType = null,
rawArtists = listOf(RealArtist.Raw(name = "Weezer"))) rawArtists = listOf(RawArtist(name = "Weezer")))
assertTrue(a != b) assertTrue(a != b)
assertTrue(a.hashCode() != b.hashCode()) assertTrue(a.hashCode() != b.hashCode())
} }
@ -99,21 +97,21 @@ class RealMusicTest {
@Test @Test
fun albumRaw_equals_inconsistentMbids() { fun albumRaw_equals_inconsistentMbids() {
val a = val a =
RealAlbum.Raw( RawAlbum(
mediaStoreId = -1, mediaStoreId = -1,
musicBrainzId = UUID.fromString("c7b245c9-8099-32ea-af95-893acedde2cf"), musicBrainzId = UUID.fromString("c7b245c9-8099-32ea-af95-893acedde2cf"),
name = "Weezer", name = "Weezer",
sortName = "Blue Album", sortName = "Blue Album",
releaseType = null, releaseType = null,
rawArtists = listOf(RealArtist.Raw(name = "Weezer"))) rawArtists = listOf(RawArtist(name = "Weezer")))
val b = val b =
RealAlbum.Raw( RawAlbum(
mediaStoreId = -1, mediaStoreId = -1,
musicBrainzId = null, musicBrainzId = null,
name = "Weezer", name = "Weezer",
sortName = "Green Album", sortName = "Green Album",
releaseType = null, releaseType = null,
rawArtists = listOf(RealArtist.Raw(name = "Weezer"))) rawArtists = listOf(RawArtist(name = "Weezer")))
assertTrue(a != b) assertTrue(a != b)
assertTrue(a.hashCode() != b.hashCode()) assertTrue(a.hashCode() != b.hashCode())
} }
@ -121,29 +119,29 @@ class RealMusicTest {
@Test @Test
fun albumRaw_equals_withRealArtists() { fun albumRaw_equals_withRealArtists() {
val a = val a =
RealAlbum.Raw( RawAlbum(
mediaStoreId = -1, mediaStoreId = -1,
musicBrainzId = null, musicBrainzId = null,
name = "Album", name = "Album",
sortName = null, sortName = null,
releaseType = null, releaseType = null,
rawArtists = listOf(RealArtist.Raw(name = "RealArtist A"))) rawArtists = listOf(RawArtist(name = "RealArtist A")))
val b = val b =
RealAlbum.Raw( RawAlbum(
mediaStoreId = -1, mediaStoreId = -1,
musicBrainzId = null, musicBrainzId = null,
name = "Album", name = "Album",
sortName = null, sortName = null,
releaseType = null, releaseType = null,
rawArtists = listOf(RealArtist.Raw(name = "RealArtist B"))) rawArtists = listOf(RawArtist(name = "RealArtist B")))
assertTrue(a != b) assertTrue(a != b)
assertTrue(a.hashCode() != b.hashCode()) assertTrue(a.hashCode() != b.hashCode())
} }
@Test @Test
fun artistRaw_equals_inconsistentCase() { fun artistRaw_equals_inconsistentCase() {
val a = RealArtist.Raw(musicBrainzId = null, name = "Parannoul") val a = RawArtist(musicBrainzId = null, name = "Parannoul")
val b = RealArtist.Raw(musicBrainzId = null, name = "parannoul") val b = RawArtist(musicBrainzId = null, name = "parannoul")
assertTrue(a == b) assertTrue(a == b)
assertTrue(a.hashCode() == b.hashCode()) assertTrue(a.hashCode() == b.hashCode())
} }
@ -151,11 +149,11 @@ class RealMusicTest {
@Test @Test
fun artistRaw_equals_withMbids() { fun artistRaw_equals_withMbids() {
val a = val a =
RealArtist.Raw( RawArtist(
musicBrainzId = UUID.fromString("677325ef-d850-44bb-8258-0d69bbc0b3f7"), musicBrainzId = UUID.fromString("677325ef-d850-44bb-8258-0d69bbc0b3f7"),
name = "Artist") name = "Artist")
val b = val b =
RealArtist.Raw( RawArtist(
musicBrainzId = UUID.fromString("6b625592-d88d-48c8-ac1a-c5b476d78bcc"), musicBrainzId = UUID.fromString("6b625592-d88d-48c8-ac1a-c5b476d78bcc"),
name = "Artist") name = "Artist")
assertTrue(a != b) assertTrue(a != b)
@ -165,50 +163,50 @@ class RealMusicTest {
@Test @Test
fun artistRaw_equals_inconsistentMbids() { fun artistRaw_equals_inconsistentMbids() {
val a = val a =
RealArtist.Raw( RawArtist(
musicBrainzId = UUID.fromString("677325ef-d850-44bb-8258-0d69bbc0b3f7"), musicBrainzId = UUID.fromString("677325ef-d850-44bb-8258-0d69bbc0b3f7"),
name = "Artist") name = "Artist")
val b = RealArtist.Raw(musicBrainzId = null, name = "Artist") val b = RawArtist(musicBrainzId = null, name = "Artist")
assertTrue(a != b) assertTrue(a != b)
assertTrue(a.hashCode() != b.hashCode()) assertTrue(a.hashCode() != b.hashCode())
} }
@Test @Test
fun artistRaw_equals_missingNames() { fun artistRaw_equals_missingNames() {
val a = RealArtist.Raw(name = null) val a = RawArtist(name = null)
val b = RealArtist.Raw(name = null) val b = RawArtist(name = null)
assertTrue(a == b) assertTrue(a == b)
assertTrue(a.hashCode() == b.hashCode()) assertTrue(a.hashCode() == b.hashCode())
} }
@Test @Test
fun artistRaw_equals_inconsistentNames() { fun artistRaw_equals_inconsistentNames() {
val a = RealArtist.Raw(name = null) val a = RawArtist(name = null)
val b = RealArtist.Raw(name = "Parannoul") val b = RawArtist(name = "Parannoul")
assertTrue(a != b) assertTrue(a != b)
assertTrue(a.hashCode() != b.hashCode()) assertTrue(a.hashCode() != b.hashCode())
} }
@Test @Test
fun genreRaw_equals_inconsistentCase() { fun genreRaw_equals_inconsistentCase() {
val a = RealGenre.Raw("Future Garage") val a = RawGenre("Future Garage")
val b = RealGenre.Raw("future garage") val b = RawGenre("future garage")
assertTrue(a == b) assertTrue(a == b)
assertTrue(a.hashCode() == b.hashCode()) assertTrue(a.hashCode() == b.hashCode())
} }
@Test @Test
fun genreRaw_equals_missingNames() { fun genreRaw_equals_missingNames() {
val a = RealGenre.Raw(name = null) val a = RawGenre(name = null)
val b = RealGenre.Raw(name = null) val b = RawGenre(name = null)
assertTrue(a == b) assertTrue(a == b)
assertTrue(a.hashCode() == b.hashCode()) assertTrue(a.hashCode() == b.hashCode())
} }
@Test @Test
fun genreRaw_equals_inconsistentNames() { fun genreRaw_equals_inconsistentNames() {
val a = RealGenre.Raw(name = null) val a = RawGenre(name = null)
val b = RealGenre.Raw(name = "Future Garage") val b = RawGenre(name = "Future Garage")
assertTrue(a != b) assertTrue(a != b)
assertTrue(a.hashCode() != b.hashCode()) assertTrue(a.hashCode() != b.hashCode())
} }

View file

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