diff --git a/app/src/main/java/org/oxycblt/auxio/music/extractor/MediaStoreExtractor.kt b/app/src/main/java/org/oxycblt/auxio/music/extractor/MediaStoreExtractor.kt index 64d1abf9d..4e847df44 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/extractor/MediaStoreExtractor.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/extractor/MediaStoreExtractor.kt @@ -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, - completeSongs: Channel + incompleteSongs: Channel, + completeSongs: Channel ) companion object { @@ -219,12 +220,12 @@ private abstract class RealMediaStoreExtractor(private val context: Context) : M override suspend fun consume( cache: MetadataCache?, - incompleteSongs: Channel, - completeSongs: Channel + incompleteSongs: Channel, + completeSongs: Channel ) { 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): 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 , 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 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 } } } diff --git a/app/src/main/java/org/oxycblt/auxio/music/extractor/MetadataCacheRepository.kt b/app/src/main/java/org/oxycblt/auxio/music/extractor/MetadataCacheRepository.kt index b89e99eaf..d8fdc1b7b 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/extractor/MetadataCacheRepository.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/extractor/MetadataCacheRepository.kt @@ -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) : MetadataCache { @@ -60,7 +61,7 @@ private class RealMetadataCache(cachedSongs: List) : 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) + suspend fun writeCache(rawSongs: List) companion object { /** @@ -124,7 +125,7 @@ private class RealMetadataCacheRepository(private val context: Context) : Metada null } - override suspend fun writeCache(rawSongs: List) { + override suspend fun writeCache(rawSongs: List) { 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 = listOf(), - /** @see RealSong.Raw.artistMusicBrainzIds */ + /** @see RawSong.artistMusicBrainzIds */ var artistMusicBrainzIds: List = listOf(), - /** @see RealSong.Raw.artistNames */ + /** @see RawSong.artistNames */ var artistNames: List = listOf(), - /** @see RealSong.Raw.artistSortNames */ + /** @see RawSong.artistSortNames */ var artistSortNames: List = listOf(), - /** @see RealSong.Raw.albumArtistMusicBrainzIds */ + /** @see RawSong.albumArtistMusicBrainzIds */ var albumArtistMusicBrainzIds: List = listOf(), - /** @see RealSong.Raw.albumArtistNames */ + /** @see RawSong.albumArtistNames */ var albumArtistNames: List = listOf(), - /** @see RealSong.Raw.albumArtistSortNames */ + /** @see RawSong.albumArtistSortNames */ var albumArtistSortNames: List = listOf(), - /** @see RealSong.Raw.genreNames */ + /** @see RawSong.genreNames */ var genreNames: List = 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" }, diff --git a/app/src/main/java/org/oxycblt/auxio/music/extractor/MetadataExtractor.kt b/app/src/main/java/org/oxycblt/auxio/music/extractor/MetadataExtractor.kt index 216d86819..52321f34c 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/extractor/MetadataExtractor.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/extractor/MetadataExtractor.kt @@ -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 = arrayOfNulls(TASK_CAPACITY) - suspend fun consume( - incompleteSongs: Channel, - completeSongs: Channel - ) { + suspend fun consume(incompleteSongs: Channel, completeSongs: Channel) { 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>) { // 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>) { // 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 } } } diff --git a/app/src/main/java/org/oxycblt/auxio/music/library/Library.kt b/app/src/main/java/org/oxycblt/auxio/music/library/Library.kt index 02ed4c59f..8c9a515fd 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/library/Library.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/library/Library.kt @@ -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, settings: MusicSettings): Library = + fun from(rawSongs: List, settings: MusicSettings): Library = RealLibrary(rawSongs, settings) } } -private class RealLibrary(rawSongs: List, settings: MusicSettings) : Library { +private class RealLibrary(rawSongs: List, 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, 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, settings: MusicSettings) = + private fun buildSongs(rawSongs: List, settings: MusicSettings) = Sort(Sort.Mode.ByName, true).songs(rawSongs.map { RealSong(it, settings) }.distinct()) /** @@ -165,7 +165,7 @@ private class RealLibrary(rawSongs: List, settings: MusicSettings) private fun buildArtists(songs: List, albums: List): List { // 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>() + val musicByArtist = mutableMapOf>() for (song in songs) { for (rawArtist in song.rawArtists) { @@ -195,7 +195,7 @@ private class RealLibrary(rawSongs: List, settings: MusicSettings) private fun buildGenres(songs: List): List { // 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>() + val songsByGenre = mutableMapOf>() for (song in songs) { for (rawGenre in song.rawGenres) { songsByGenre.getOrPut(rawGenre) { mutableListOf() }.add(song) diff --git a/app/src/main/java/org/oxycblt/auxio/music/library/RawMusic.kt b/app/src/main/java/org/oxycblt/auxio/music/library/RawMusic.kt new file mode 100644 index 000000000..e9f2711cd --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/music/library/RawMusic.kt @@ -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 . + */ + +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 = listOf(), + /** @see RawArtist.musicBrainzId */ + var artistMusicBrainzIds: List = listOf(), + /** @see RawArtist.name */ + var artistNames: List = listOf(), + /** @see RawArtist.sortName */ + var artistSortNames: List = listOf(), + /** @see RawArtist.musicBrainzId */ + var albumArtistMusicBrainzIds: List = listOf(), + /** @see RawArtist.name */ + var albumArtistNames: List = listOf(), + /** @see RawArtist.sortName */ + var albumArtistSortNames: List = listOf(), + /** @see RawGenre.name */ + var genreNames: List = 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 +) { + // 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 + } +} diff --git a/app/src/main/java/org/oxycblt/auxio/music/library/RealMusic.kt b/app/src/main/java/org/oxycblt/auxio/music/library/RealMusic.kt index 6a993d0aa..496c89164 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/library/RealMusic.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/library/RealMusic.kt @@ -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 = listOf(), - /** @see RealArtist.Raw.musicBrainzId */ - var artistMusicBrainzIds: List = listOf(), - /** @see RealArtist.Raw.name */ - var artistNames: List = listOf(), - /** @see RealArtist.Raw.sortName */ - var artistSortNames: List = listOf(), - /** @see RealArtist.Raw.musicBrainzId */ - var albumArtistMusicBrainzIds: List = listOf(), - /** @see RealArtist.Raw.name */ - var albumArtistNames: List = listOf(), - /** @see RealArtist.Raw.sortName */ - var albumArtistSortNames: List = listOf(), - /** @see RealGenre.Raw.name */ - var genreNames: List = 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) : Album { +class RealAlbum(val rawAlbum: RawAlbum, override val songs: List) : 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) : 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) : 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 - ) { - // 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) : Artist { +class RealArtist(private val rawArtist: RawArtist, songAlbums: List) : 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 @@ -509,14 +402,14 @@ class RealArtist(private val raw: Raw, songAlbums: List) : 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) = rawArtists.indexOf(raw) + fun getOriginalPositionIn(rawArtists: List) = rawArtists.indexOf(rawArtist) /** * Perform final validation and organization on this instance. @@ -530,55 +423,14 @@ class RealArtist(private val raw: Raw, songAlbums: List) : 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) : 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) : 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) : 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) = rawGenres.indexOf(raw) + fun getOriginalPositionIn(rawGenres: List) = 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) : 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 - } - } } /** diff --git a/app/src/main/java/org/oxycblt/auxio/music/system/Indexer.kt b/app/src/main/java/org/oxycblt/auxio/music/system/Indexer.kt index cf8ce9c28..9ebbe2ce2 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/system/Indexer.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/system/Indexer.kt @@ -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(Channel.UNLIMITED) - val incompleteSongs = Channel(Channel.UNLIMITED) + val completeSongs = Channel(Channel.UNLIMITED) + val incompleteSongs = Channel(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() + val rawSongs = LinkedList() for (rawSong in completeSongs) { rawSongs.add(rawSong) emitIndexing(Indexer.Indexing.Songs(rawSongs.size, total)) diff --git a/app/src/main/java/org/oxycblt/auxio/music/system/IndexerService.kt b/app/src/main/java/org/oxycblt/auxio/music/system/IndexerService.kt index 6bae83f41..1eb47d5f7 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/system/IndexerService.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/system/IndexerService.kt @@ -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 diff --git a/app/src/test/java/org/oxycblt/auxio/music/library/LibraryTest.kt b/app/src/test/java/org/oxycblt/auxio/music/library/LibraryTest.kt deleted file mode 100644 index 42263b2bb..000000000 --- a/app/src/test/java/org/oxycblt/auxio/music/library/LibraryTest.kt +++ /dev/null @@ -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 . - */ - -package org.oxycblt.auxio.music.library - -class LibraryTest { - fun library_common() {} - - fun library_sparse() {} - - fun library_multiArtist() {} - - fun library_multiGenre() {} - - fun library_musicBrainz() {} -} diff --git a/app/src/test/java/org/oxycblt/auxio/music/library/RealMusicTest.kt b/app/src/test/java/org/oxycblt/auxio/music/library/RawMusicTest.kt similarity index 76% rename from app/src/test/java/org/oxycblt/auxio/music/library/RealMusicTest.kt rename to app/src/test/java/org/oxycblt/auxio/music/library/RawMusicTest.kt index bac8c242e..285522015 100644 --- a/app/src/test/java/org/oxycblt/auxio/music/library/RealMusicTest.kt +++ b/app/src/test/java/org/oxycblt/auxio/music/library/RawMusicTest.kt @@ -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()) } diff --git a/app/src/test/java/org/oxycblt/auxio/music/extractor/TextTagsTest.kt b/app/src/test/java/org/oxycblt/auxio/music/metadata/TextTagsTest.kt similarity index 98% rename from app/src/test/java/org/oxycblt/auxio/music/extractor/TextTagsTest.kt rename to app/src/test/java/org/oxycblt/auxio/music/metadata/TextTagsTest.kt index 17d8b3e76..3a584f021 100644 --- a/app/src/test/java/org/oxycblt/auxio/music/extractor/TextTagsTest.kt +++ b/app/src/test/java/org/oxycblt/auxio/music/metadata/TextTagsTest.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -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