diff --git a/app/src/main/java/org/oxycblt/auxio/music/Music.kt b/app/src/main/java/org/oxycblt/auxio/music/Music.kt
index 3489f085e..c2659961e 100644
--- a/app/src/main/java/org/oxycblt/auxio/music/Music.kt
+++ b/app/src/main/java/org/oxycblt/auxio/music/Music.kt
@@ -1,5 +1,5 @@
/*
- * Copyright (c) 2021 Auxio Project
+ * 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
@@ -15,51 +15,41 @@
* along with this program. If not, see .
*/
-@file:Suppress("PropertyName", "FunctionName")
-
package org.oxycblt.auxio.music
import android.content.Context
+import android.net.Uri
import android.os.Parcelable
-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 kotlinx.parcelize.IgnoredOnParcel
import kotlinx.parcelize.Parcelize
-import org.oxycblt.auxio.R
import org.oxycblt.auxio.list.Item
import org.oxycblt.auxio.music.format.Date
import org.oxycblt.auxio.music.format.Disc
import org.oxycblt.auxio.music.format.ReleaseType
-import org.oxycblt.auxio.music.library.Sort
-import org.oxycblt.auxio.music.parsing.parseId3GenreNames
-import org.oxycblt.auxio.music.parsing.parseMultiValue
-import org.oxycblt.auxio.music.storage.*
-import org.oxycblt.auxio.util.nonZeroOrNull
-import org.oxycblt.auxio.util.unlikelyToBeNull
-
-// --- MUSIC MODELS ---
+import org.oxycblt.auxio.music.storage.MimeType
+import org.oxycblt.auxio.music.storage.Path
+import org.oxycblt.auxio.util.toUuidOrNull
/**
* Abstract music data. This contains universal information about all concrete music
* implementations, such as identification information and names.
* @author Alexander Capehart (OxygenCobalt)
*/
-sealed class Music : Item {
+sealed interface Music : Item {
/**
* A unique identifier for this music item.
* @see UID
*/
- abstract val uid: UID
+ val uid: UID
/**
* The raw name of this item as it was extracted from the file-system. Will be null if the
* item's name is unknown. When showing this item in a UI, avoid this in favor of [resolveName].
*/
- abstract val rawName: String?
+ val rawName: String?
/**
* Returns a name suitable for use in the app UI. This should be favored over [rawName] in
@@ -68,14 +58,14 @@ sealed class Music : Item {
* @return A human-readable string representing the name of this music. In the case that the
* item does not have a name, an analogous "Unknown X" name is returned.
*/
- abstract fun resolveName(context: Context): String
+ fun resolveName(context: Context): String
/**
* The raw sort name of this item as it was extracted from the file-system. This can be used not
* only when sorting music, but also trying to locate music based on a fuzzy search by the user.
* Will be null if the item has no known sort name.
*/
- abstract val rawSortName: String?
+ val rawSortName: String?
/**
* A [CollationKey] derived from [rawName] and [rawSortName] that can be used to sort items in a
@@ -86,62 +76,7 @@ sealed class Music : Item {
* - If the string begins with an article, such as "the", it will be stripped, as is usually
* convention for sorting media. This is not internationalized.
*/
- abstract val collationKey: CollationKey?
-
- /**
- * Finalize this item once the music library has been fully constructed. This is where any final
- * ordering or sanity checking should occur. **This function is internal to the music package.
- * Do not use it elsewhere.**
- */
- abstract fun _finalize()
-
- /**
- * Provided implementation to create a [CollationKey] in the way described by [collationKey].
- * This should be used in all overrides of all [CollationKey].
- * @return A [CollationKey] that follows the specification described by [collationKey].
- */
- protected fun makeCollationKeyImpl(): CollationKey? {
- val sortName =
- (rawSortName ?: rawName)?.run {
- when {
- length > 5 && startsWith("the ", ignoreCase = true) -> substring(4)
- length > 4 && startsWith("an ", ignoreCase = true) -> substring(3)
- length > 3 && startsWith("a ", ignoreCase = true) -> substring(2)
- else -> this
- }
- }
-
- return COLLATOR.getCollationKey(sortName)
- }
-
- /**
- * Join a list of [Music]'s resolved names into a string in a localized manner, using
- * [R.string.fmt_list].
- * @param context [Context] required to obtain localized formatting.
- * @param values The list of [Music] to format.
- * @return A single string consisting of the values delimited by a localized separator.
- */
- protected fun resolveNames(context: Context, values: List): String {
- if (values.isEmpty()) {
- // Nothing to do.
- return ""
- }
-
- var joined = values.first().resolveName(context)
- for (i in 1..values.lastIndex) {
- // Chain all previous values with the next value in the list with another delimiter.
- joined = context.getString(R.string.fmt_list, joined, values[i].resolveName(context))
- }
- return joined
- }
-
- // Note: We solely use the UID in comparisons so that certain items that differ in all
- // but UID are treated differently.
-
- override fun hashCode() = uid.hashCode()
-
- override fun equals(other: Any?) =
- other is Music && javaClass == other.javaClass && uid == other.uid
+ val collationKey: CollationKey?
/**
* A unique identifier for a piece of music.
@@ -193,6 +128,7 @@ sealed class Music : Item {
private enum class Format(val namespace: String) {
/** @see auxio */
AUXIO("org.oxycblt.auxio"),
+
/** @see musicBrainz */
MUSICBRAINZ("org.musicbrainz")
}
@@ -282,501 +218,120 @@ sealed class Music : Item {
}
}
}
-
- private companion object {
- /** Cached collator instance re-used with [makeCollationKeyImpl]. */
- val COLLATOR: Collator = Collator.getInstance().apply { strength = Collator.PRIMARY }
- }
}
/**
* An abstract grouping of [Song]s and other [Music] data.
* @author Alexander Capehart (OxygenCobalt)
*/
-sealed class MusicParent : Music() {
- /** The [Song]s in this this group. */
- abstract val songs: List
-
- // Note: Append song contents to MusicParent equality so that Groups with
- // the same UID but different contents are not equal.
-
- override fun hashCode() = 31 * uid.hashCode() + songs.hashCode()
-
- override fun equals(other: Any?) =
- other is MusicParent &&
- javaClass == other.javaClass &&
- uid == other.uid &&
- songs == other.songs
+sealed interface MusicParent : Music {
+ val songs: List
}
/**
- * A song. Perhaps the foundation of the entirety of Auxio.
- * @param raw The [Song.Raw] to derive the member data from.
- * @param musicSettings [MusicSettings] to perform further user-configured parsing.
+ * A song.
* @author Alexander Capehart (OxygenCobalt)
*/
-class Song constructor(raw: Raw, musicSettings: MusicSettings) : Music() {
- override val uid =
- // Attempt to use a MusicBrainz ID first before falling back to a hashed UID.
- raw.musicBrainzId?.toUuidOrNull()?.let { UID.musicBrainz(MusicMode.SONGS, it) }
- ?: 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(raw.track)
- update(raw.disc)
-
- update(raw.artistNames)
- update(raw.albumArtistNames)
- }
- override val rawName = requireNotNull(raw.name) { "Invalid raw: No title" }
- override val rawSortName = raw.sortName
- override val collationKey = makeCollationKeyImpl()
- override fun resolveName(context: Context) = rawName
-
+interface Song : Music {
/** The track number. Will be null if no valid track number was present in the metadata. */
- val track = raw.track
-
+ val track: Int?
/** The [Disc] number. Will be null if no valid disc number was present in the metadata. */
- val disc = raw.disc?.let { Disc(it, raw.subtitle) }
-
+ val disc: Disc?
/** The release [Date]. Will be null if no valid date was present in the metadata. */
- val date = raw.date
-
+ val date: Date?
/**
* The URI to the audio file that this instance was created from. This can be used to access the
* audio file in a way that is scoped-storage-safe.
*/
- val uri = requireNotNull(raw.mediaStoreId) { "Invalid raw: No id" }.toAudioUri()
-
+ val uri: Uri
/**
* The [Path] to this audio file. This is only intended for display, [uri] should be favored
* instead for accessing the audio file.
*/
- val path =
- Path(
- name = requireNotNull(raw.fileName) { "Invalid raw: No display name" },
- parent = requireNotNull(raw.directory) { "Invalid raw: No parent directory" })
-
+ val path: Path
/** The [MimeType] of the audio file. Only intended for display. */
- val mimeType =
- MimeType(
- fromExtension = requireNotNull(raw.extensionMimeType) { "Invalid raw: No mime type" },
- fromFormat = null)
-
+ val mimeType: MimeType
/** The size of the audio file, in bytes. */
- val size = requireNotNull(raw.size) { "Invalid raw: No size" }
-
+ val size: Long
/** The duration of the audio file, in milliseconds. */
- val durationMs = requireNotNull(raw.durationMs) { "Invalid raw: No duration" }
-
+ val durationMs: Long
/** The date the audio file was added to the device, as a unix epoch timestamp. */
- val dateAdded = requireNotNull(raw.dateAdded) { "Invalid raw: No date added" }
-
- private var _album: Album? = null
+ val dateAdded: Long
/**
* The parent [Album]. If the metadata did not specify an album, it's parent directory is used
* instead.
*/
val album: Album
- get() = unlikelyToBeNull(_album)
-
- private val artistMusicBrainzIds = raw.artistMusicBrainzIds.parseMultiValue(musicSettings)
- private val artistNames = raw.artistNames.parseMultiValue(musicSettings)
- private val artistSortNames = raw.artistSortNames.parseMultiValue(musicSettings)
- private val rawArtists =
- artistNames.mapIndexed { i, name ->
- Artist.Raw(
- 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)
- private val rawAlbumArtists =
- albumArtistNames.mapIndexed { i, name ->
- Artist.Raw(
- albumArtistMusicBrainzIds.getOrNull(i)?.toUuidOrNull(),
- name,
- albumArtistSortNames.getOrNull(i))
- }
-
- private val _artists = mutableListOf()
/**
* The parent [Artist]s of this [Song]. Is often one, but there can be multiple if more than one
* [Artist] name was specified in the metadata. Unliked [Album], artists are prioritized for
* this field.
*/
val artists: List
- get() = _artists
-
/**
* Resolves one or more [Artist]s into a single piece of human-readable names.
* @param context [Context] required for [resolveName]. formatter.
*/
- fun resolveArtistContents(context: Context) = resolveNames(context, artists)
-
+ fun resolveArtistContents(context: Context): String
/**
* Checks if the [Artist] *display* of this [Song] and another [Song] are equal. This will only
* compare surface-level names, and not [Music.UID]s.
* @param other The [Song] to compare to.
* @return True if the [Artist] displays are equal, false otherwise
*/
- fun areArtistContentsTheSame(other: Song): Boolean {
- for (i in 0 until max(artists.size, other.artists.size)) {
- val a = artists.getOrNull(i) ?: return false
- val b = other.artists.getOrNull(i) ?: return false
- if (a.rawName != b.rawName) {
- return false
- }
- }
-
- return true
- }
-
- private val _genres = mutableListOf()
+ fun areArtistContentsTheSame(other: Song): Boolean
/**
* The parent [Genre]s of this [Song]. Is often one, but there can be multiple if more than one
* [Genre] name was specified in the metadata.
*/
val genres: List
- get() = _genres
-
/**
* Resolves one or more [Genre]s into a single piece human-readable names.
* @param context [Context] required for [resolveName].
*/
- fun resolveGenreContents(context: Context) = resolveNames(context, genres)
-
- // --- INTERNAL FIELDS ---
-
- /**
- * The [Album.Raw] instances collated by the [Song]. This can be used to group [Song]s into an
- * [Album]. **This is only meant for use within the music package.**
- */
- val _rawAlbum =
- Album.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)),
- rawArtists =
- rawAlbumArtists.ifEmpty { rawArtists }.ifEmpty { listOf(Artist.Raw(null, null)) })
-
- /**
- * The [Artist.Raw] instances collated by the [Song]. The artists of the song take priority,
- * followed by the album artists. If there are no artists, this field will be a single "unknown"
- * [Artist.Raw]. This can be used to group up [Song]s into an [Artist]. **This is only meant for
- * use within the music package.**
- */
- val _rawArtists = rawArtists.ifEmpty { rawAlbumArtists }.ifEmpty { listOf(Artist.Raw()) }
-
- /**
- * The [Genre.Raw] instances collated by the [Song]. This can be used to group up [Song]s into a
- * [Genre]. ID3v2 Genre names are automatically converted to their resolved names. **This is
- * only meant for use within the music package.**
- */
- val _rawGenres =
- raw.genreNames
- .parseId3GenreNames(musicSettings)
- .map { Genre.Raw(it) }
- .ifEmpty { listOf(Genre.Raw()) }
-
- /**
- * Links this [Song] with a parent [Album].
- * @param album The parent [Album] to link to. **This is only meant for use within the music
- * package.**
- */
- fun _link(album: Album) {
- _album = album
- }
-
- /**
- * Links this [Song] with a parent [Artist].
- * @param artist The parent [Artist] to link to. **This is only meant for use within the music
- * package.**
- */
- fun _link(artist: Artist) {
- _artists.add(artist)
- }
-
- /**
- * Links this [Song] with a parent [Genre].
- * @param genre The parent [Genre] to link to. **This is only meant for use within the music
- * package.**
- */
- fun _link(genre: Genre) {
- _genres.add(genre)
- }
-
- override fun _finalize() {
- checkNotNull(_album) { "Malformed song: No album" }
-
- check(_artists.isNotEmpty()) { "Malformed song: No artists" }
- for (i in _artists.indices) {
- // Non-destructively reorder the linked artists so that they align with
- // the artist ordering within the song metadata.
- val newIdx = _artists[i]._getOriginalPositionIn(_rawArtists)
- val other = _artists[newIdx]
- _artists[newIdx] = _artists[i]
- _artists[i] = other
- }
-
- check(_genres.isNotEmpty()) { "Malformed song: No genres" }
- for (i in _genres.indices) {
- // Non-destructively reorder the linked genres so that they align with
- // the genre ordering within the song metadata.
- val newIdx = _genres[i]._getOriginalPositionIn(_rawGenres)
- val other = _genres[newIdx]
- _genres[newIdx] = _genres[i]
- _genres[i] = other
- }
- }
-
- /**
- * Raw information about a [Song] obtained from the filesystem/Extractor instances. **This is
- * only meant for use within the music package.**
- */
- class Raw
- constructor(
- /**
- * The ID of the [Song]'s audio file, obtained from MediaStore. Note that this ID is highly
- * unstable and should only be used for accessing the audio file.
- */
- var mediaStoreId: Long? = null,
- /** @see Song.dateAdded */
- var dateAdded: Long? = null,
- /** The latest date the [Song]'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 Album.Raw.mediaStoreId */
- var albumMediaStoreId: Long? = null,
- /** @see Album.Raw.musicBrainzId */
- var albumMusicBrainzId: String? = null,
- /** @see Album.Raw.name */
- var albumName: String? = null,
- /** @see Album.Raw.sortName */
- var albumSortName: String? = null,
- /** @see Album.Raw.releaseType */
- var releaseTypes: List = listOf(),
- /** @see Artist.Raw.musicBrainzId */
- var artistMusicBrainzIds: List = listOf(),
- /** @see Artist.Raw.name */
- var artistNames: List = listOf(),
- /** @see Artist.Raw.sortName */
- var artistSortNames: List = listOf(),
- /** @see Artist.Raw.musicBrainzId */
- var albumArtistMusicBrainzIds: List = listOf(),
- /** @see Artist.Raw.name */
- var albumArtistNames: List = listOf(),
- /** @see Artist.Raw.sortName */
- var albumArtistSortNames: List = listOf(),
- /** @see Genre.Raw.name */
- var genreNames: List = listOf()
- )
+ fun resolveGenreContents(context: Context): String
}
/**
* An abstract release group. While it may be called an album, it encompasses other types of
* releases like singles, EPs, and compilations.
- * @param raw The [Album.Raw] to derive the member data from.
- * @param songs The [Song]s that are a part of this [Album]. These items will be linked to this
- * [Album].
* @author Alexander Capehart (OxygenCobalt)
*/
-class Album constructor(raw: Raw, override val songs: List) : MusicParent() {
- override val uid =
- // Attempt to use a MusicBrainz ID first before falling back to a hashed UID.
- raw.musicBrainzId?.let { UID.musicBrainz(MusicMode.ALBUMS, it) }
- ?: 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 })
- }
- override val rawName = raw.name
- override val rawSortName = raw.sortName
- override val collationKey = makeCollationKeyImpl()
- override fun resolveName(context: Context) = rawName
-
+interface Album : MusicParent {
/** The [Date.Range] that [Song]s in the [Album] were released. */
- val dates = Date.Range.from(songs.mapNotNull { it.date })
-
+ val dates: Date.Range?
/**
* The [ReleaseType] of this album, signifying the type of release it actually is. Defaults to
* [ReleaseType.Album].
*/
- val releaseType = raw.releaseType ?: ReleaseType.Album(null)
+ val releaseType: ReleaseType
/**
* The URI to a MediaStore-provided album cover. These images will be fast to load, but at the
* cost of image quality.
*/
- val coverUri = raw.mediaStoreId.toCoverUri()
-
+ val coverUri: Uri
/** The duration of all songs in the album, in milliseconds. */
val durationMs: Long
-
/** The earliest date a song in this album was added, as a unix epoch timestamp. */
val dateAdded: Long
-
- init {
- var totalDuration: Long = 0
- var earliestDateAdded: Long = Long.MAX_VALUE
-
- // Do linking and value generation in the same loop for efficiency.
- for (song in songs) {
- song._link(this)
- if (song.dateAdded < earliestDateAdded) {
- earliestDateAdded = song.dateAdded
- }
- totalDuration += song.durationMs
- }
-
- durationMs = totalDuration
- dateAdded = earliestDateAdded
- }
-
- private val _artists = mutableListOf()
/**
* The parent [Artist]s of this [Album]. Is often one, but there can be multiple if more than
* one [Artist] name was specified in the metadata of the [Song]'s. Unlike [Song], album artists
* are prioritized for this field.
*/
val artists: List
- get() = _artists
-
/**
* Resolves one or more [Artist]s into a single piece of human-readable names.
* @param context [Context] required for [resolveName].
*/
- fun resolveArtistContents(context: Context) = resolveNames(context, artists)
-
+ fun resolveArtistContents(context: Context): String
/**
* Checks if the [Artist] *display* of this [Album] and another [Album] are equal. This will
* only compare surface-level names, and not [Music.UID]s.
* @param other The [Album] to compare to.
* @return True if the [Artist] displays are equal, false otherwise
*/
- fun areArtistContentsTheSame(other: Album): Boolean {
- for (i in 0 until max(artists.size, other.artists.size)) {
- val a = artists.getOrNull(i) ?: return false
- val b = other.artists.getOrNull(i) ?: return false
- if (a.rawName != b.rawName) {
- return false
- }
- }
-
- return true
- }
-
- // --- INTERNAL FIELDS ---
-
- /**
- * The [Artist.Raw] instances collated by the [Album]. The album artists of the song take
- * priority, followed by the artists. If there are no artists, this field will be a single
- * "unknown" [Artist.Raw]. This can be used to group up [Album]s into an [Artist]. **This is
- * only meant for use within the music package.**
- */
- val _rawArtists = raw.rawArtists
-
- /**
- * Links this [Album] with a parent [Artist].
- * @param artist The parent [Artist] to link to. **This is only meant for use within the music
- * package.**
- */
- fun _link(artist: Artist) {
- _artists.add(artist)
- }
-
- override fun _finalize() {
- check(songs.isNotEmpty()) { "Malformed album: Empty" }
- check(_artists.isNotEmpty()) { "Malformed album: No artists" }
- for (i in _artists.indices) {
- // Non-destructively reorder the linked artists so that they align with
- // the artist ordering within the song metadata.
- val newIdx = _artists[i]._getOriginalPositionIn(_rawArtists)
- val other = _artists[newIdx]
- _artists[newIdx] = _artists[i]
- _artists[i] = other
- }
- }
-
- /**
- * Raw information about an [Album] obtained from the component [Song] instances. **This is only
- * meant for use within the music package.**
- */
- class Raw(
- /**
- * The ID of the [Album]'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 Artist.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
- }
- }
+ fun areArtistContentsTheSame(other: Album): Boolean
}
/**
@@ -788,295 +343,48 @@ class Album constructor(raw: Raw, override val songs: List) : MusicParent(
* will be linked to this [Artist].
* @author Alexander Capehart (OxygenCobalt)
*/
-class Artist constructor(private val raw: Raw, songAlbums: List) : MusicParent() {
- override val uid =
- // Attempt to use a MusicBrainz ID first before falling back to a hashed UID.
- raw.musicBrainzId?.let { UID.musicBrainz(MusicMode.ARTISTS, it) }
- ?: UID.auxio(MusicMode.ARTISTS) { update(raw.name) }
- override val rawName = raw.name
- override val rawSortName = raw.sortName
- override val collationKey = makeCollationKeyImpl()
- override fun resolveName(context: Context) = rawName ?: context.getString(R.string.def_artist)
- override val songs: List
-
+interface Artist : MusicParent {
/**
* All of the [Album]s this artist is credited to. Note that any [Song] credited to this artist
* will have it's [Album] considered to be "indirectly" linked to this [Artist], and thus
* included in this list.
*/
val albums: List
-
/**
* The duration of all [Song]s in the artist, in milliseconds. Will be null if there are no
* songs.
*/
val durationMs: Long?
-
/**
* Whether this artist is considered a "collaborator", i.e it is not directly credited on any
* [Album].
*/
val isCollaborator: Boolean
-
- init {
- val distinctSongs = mutableSetOf()
- val distinctAlbums = mutableSetOf()
-
- var noAlbums = true
-
- for (music in songAlbums) {
- when (music) {
- is Song -> {
- music._link(this)
- distinctSongs.add(music)
- distinctAlbums.add(music.album)
- }
- is Album -> {
- music._link(this)
- distinctAlbums.add(music)
- noAlbums = false
- }
- else -> error("Unexpected input music ${music::class.simpleName}")
- }
- }
-
- songs = distinctSongs.toList()
- albums = distinctAlbums.toList()
- durationMs = songs.sumOf { it.durationMs }.nonZeroOrNull()
- isCollaborator = noAlbums
- }
-
- private lateinit var genres: List
-
+ /** The [Genre]s of this artist. */
+ val genres: List
/**
* Resolves one or more [Genre]s into a single piece of human-readable names.
* @param context [Context] required for [resolveName].
*/
- fun resolveGenreContents(context: Context) = resolveNames(context, genres)
-
+ fun resolveGenreContents(context: Context): String
/**
* Checks if the [Genre] *display* of this [Artist] and another [Artist] are equal. This will
* only compare surface-level names, and not [Music.UID]s.
* @param other The [Artist] to compare to.
* @return True if the [Genre] displays are equal, false otherwise
*/
- fun areGenreContentsTheSame(other: Artist): Boolean {
- for (i in 0 until max(genres.size, other.genres.size)) {
- val a = genres.getOrNull(i) ?: return false
- val b = other.genres.getOrNull(i) ?: return false
- if (a.rawName != b.rawName) {
- return false
- }
- }
-
- return true
- }
-
- // --- INTERNAL METHODS ---
-
- /**
- * Returns the original position of this [Artist]'s [Artist.Raw] within the given [Artist.Raw]
- * list. This can be used to create a consistent ordering within child [Artist] lists based on
- * the original tag order.
- * @param rawArtists The [Artist.Raw] instances to check. It is assumed that this [Artist]'s
- * [Artist.Raw] will be within the list.
- * @return The index of the [Artist]'s [Artist.Raw] within the list. **This is only meant for
- * use within the music package.**
- */
- fun _getOriginalPositionIn(rawArtists: List) = rawArtists.indexOf(raw)
-
- override fun _finalize() {
- check(songs.isNotEmpty() || albums.isNotEmpty()) { "Malformed artist: Empty" }
- genres =
- Sort(Sort.Mode.ByName, true)
- .genres(songs.flatMapTo(mutableSetOf()) { it.genres })
- .sortedByDescending { genre -> songs.count { it.genres.contains(genre) } }
- }
-
- /**
- * Raw information about an [Artist] obtained from the component [Song] and [Album] instances.
- * **This is only meant for use within the music package.**
- */
- 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
- }
- }
+ fun areGenreContentsTheSame(other: Artist): Boolean
}
/**
- * A genre of [Song]s.
+ * A genre.
* @author Alexander Capehart (OxygenCobalt)
*/
-class Genre constructor(private val raw: Raw, override val songs: List) : MusicParent() {
- override val uid = UID.auxio(MusicMode.GENRES) { update(raw.name) }
- override val rawName = raw.name
- override val rawSortName = rawName
- override val collationKey = makeCollationKeyImpl()
- override fun resolveName(context: Context) = rawName ?: context.getString(R.string.def_genre)
-
+interface Genre : MusicParent {
/** The albums indirectly linked to by the [Song]s of this [Genre]. */
val albums: List
-
/** The artists indirectly linked to by the [Artist]s of this [Genre]. */
val artists: List
-
/** The total duration of the songs in this genre, in milliseconds. */
val durationMs: Long
-
- init {
- val distinctAlbums = mutableSetOf()
- val distinctArtists = mutableSetOf()
- var totalDuration = 0L
-
- for (song in songs) {
- song._link(this)
- distinctAlbums.add(song.album)
- distinctArtists.addAll(song.artists)
- totalDuration += song.durationMs
- }
-
- albums =
- Sort(Sort.Mode.ByName, true).albums(distinctAlbums).sortedByDescending { album ->
- album.songs.count { it.genres.contains(this) }
- }
- artists = Sort(Sort.Mode.ByName, true).artists(distinctArtists)
- durationMs = totalDuration
- }
-
- // --- INTERNAL METHODS ---
-
- /**
- * Returns the original position of this [Genre]'s [Genre.Raw] within the given [Genre.Raw]
- * list. This can be used to create a consistent ordering within child [Genre] lists based on
- * the original tag order.
- * @param rawGenres The [Genre.Raw] instances to check. It is assumed that this [Genre]'s
- * [Genre.Raw] will be within the list.
- * @return The index of the [Genre]'s [Genre.Raw] within the list. **This is only meant for use
- * within the music package.**
- */
- fun _getOriginalPositionIn(rawGenres: List) = rawGenres.indexOf(raw)
-
- override fun _finalize() {
- check(songs.isNotEmpty()) { "Malformed genre: Empty" }
- }
-
- /**
- * Raw information about a [Genre] obtained from the component [Song] instances. **This is only
- * meant for use within the music package.**
- */
- 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
- }
- }
-}
-
-// --- MUSIC UID CREATION UTILITIES ---
-
-/**
- * Convert a [String] to a [UUID].
- * @return A [UUID] converted from the [String] value, or null if the value was not valid.
- * @see UUID.fromString
- */
-private fun String.toUuidOrNull(): UUID? =
- try {
- UUID.fromString(this)
- } catch (e: IllegalArgumentException) {
- null
- }
-
-/**
- * Update a [MessageDigest] with a lowercase [String].
- * @param string The [String] to hash. If null, it will not be hashed.
- */
-@VisibleForTesting
-fun MessageDigest.update(string: String?) {
- if (string != null) {
- update(string.lowercase().toByteArray())
- } else {
- update(0)
- }
-}
-
-/**
- * Update a [MessageDigest] with the string representation of a [Date].
- * @param date The [Date] to hash. If null, nothing will be done.
- */
-@VisibleForTesting
-fun MessageDigest.update(date: Date?) {
- if (date != null) {
- update(date.toString().toByteArray())
- } else {
- update(0)
- }
-}
-
-/**
- * Update a [MessageDigest] with the lowercase versions of all of the input [String]s.
- * @param strings The [String]s to hash. If a [String] is null, it will not be hashed.
- */
-@VisibleForTesting
-fun MessageDigest.update(strings: List) {
- strings.forEach(::update)
-}
-
-/**
- * Update a [MessageDigest] with the little-endian bytes of a [Int].
- * @param n The [Int] to write. If null, nothing will be done.
- */
-@VisibleForTesting
-fun MessageDigest.update(n: Int?) {
- if (n != null) {
- update(byteArrayOf(n.toByte(), n.shr(8).toByte(), n.shr(16).toByte(), n.shr(24).toByte()))
- } else {
- update(0)
- }
}
diff --git a/app/src/main/java/org/oxycblt/auxio/music/RealMusic.kt b/app/src/main/java/org/oxycblt/auxio/music/RealMusic.kt
new file mode 100644
index 000000000..9b2f0b65b
--- /dev/null
+++ b/app/src/main/java/org/oxycblt/auxio/music/RealMusic.kt
@@ -0,0 +1,739 @@
+/*
+ * 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
+
+import android.content.Context
+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.format.Date
+import org.oxycblt.auxio.music.format.Disc
+import org.oxycblt.auxio.music.format.ReleaseType
+import org.oxycblt.auxio.music.library.Sort
+import org.oxycblt.auxio.music.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
+import org.oxycblt.auxio.music.storage.toCoverUri
+import org.oxycblt.auxio.util.nonZeroOrNull
+import org.oxycblt.auxio.util.toUuidOrNull
+import org.oxycblt.auxio.util.unlikelyToBeNull
+
+/**
+ * Library-backed implementation of [RealSong].
+ * @param raw The [Raw] 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 {
+ 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) }
+ ?: 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(raw.track)
+ update(raw.disc)
+
+ update(raw.artistNames)
+ update(raw.albumArtistNames)
+ }
+ override val rawName = requireNotNull(raw.name) { "Invalid raw: No title" }
+ override val rawSortName = raw.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 path =
+ Path(
+ name = requireNotNull(raw.fileName) { "Invalid raw: No display name" },
+ parent = requireNotNull(raw.directory) { "Invalid raw: No parent directory" })
+ override val mimeType =
+ MimeType(
+ fromExtension = requireNotNull(raw.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" }
+ private var _album: RealAlbum? = null
+ override val album: Album
+ get() = unlikelyToBeNull(_album)
+
+ // Note: Only compare by UID so songs that differ only in MBID are treated differently.
+ 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 rawIndividualArtists =
+ artistNames.mapIndexed { i, name ->
+ RealArtist.Raw(
+ 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)
+ private val rawAlbumArtists =
+ albumArtistNames.mapIndexed { i, name ->
+ RealArtist.Raw(
+ albumArtistMusicBrainzIds.getOrNull(i)?.toUuidOrNull(),
+ name,
+ albumArtistSortNames.getOrNull(i))
+ }
+
+ private val _artists = mutableListOf()
+ override val artists: List
+ get() = _artists
+ override fun resolveArtistContents(context: Context) = resolveNames(context, artists)
+ override fun areArtistContentsTheSame(other: Song): Boolean {
+ for (i in 0 until max(artists.size, other.artists.size)) {
+ val a = artists.getOrNull(i) ?: return false
+ val b = other.artists.getOrNull(i) ?: return false
+ if (a.rawName != b.rawName) {
+ return false
+ }
+ }
+
+ return true
+ }
+
+ private val _genres = mutableListOf()
+ override val genres: List
+ get() = _genres
+ 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].
+ */
+ 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)),
+ rawArtists =
+ rawAlbumArtists
+ .ifEmpty { rawIndividualArtists }
+ .ifEmpty { listOf(RealArtist.Raw(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].
+ */
+ val rawArtists =
+ rawIndividualArtists.ifEmpty { rawAlbumArtists }.ifEmpty { listOf(RealArtist.Raw()) }
+
+ /**
+ * 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.
+ */
+ val rawGenres =
+ raw.genreNames
+ .parseId3GenreNames(musicSettings)
+ .map { RealGenre.Raw(it) }
+ .ifEmpty { listOf(RealGenre.Raw()) }
+
+ /**
+ * Links this [RealSong] with a parent [RealAlbum].
+ * @param album The parent [RealAlbum] to link to.
+ */
+ fun link(album: RealAlbum) {
+ _album = album
+ }
+
+ /**
+ * Links this [RealSong] with a parent [RealArtist].
+ * @param artist The parent [RealArtist] to link to.
+ */
+ fun link(artist: RealArtist) {
+ _artists.add(artist)
+ }
+
+ /**
+ * Links this [RealSong] with a parent [RealGenre].
+ * @param genre The parent [RealGenre] to link to.
+ */
+ fun link(genre: RealGenre) {
+ _genres.add(genre)
+ }
+
+ /**
+ * Perform final validation and organization on this instance.
+ * @return This instance upcasted to [Song].
+ */
+ fun finalize(): Song {
+ checkNotNull(_album) { "Malformed song: No album" }
+
+ check(_artists.isNotEmpty()) { "Malformed song: No artists" }
+ for (i in _artists.indices) {
+ // Non-destructively reorder the linked artists so that they align with
+ // the artist ordering within the song metadata.
+ val newIdx = _artists[i].getOriginalPositionIn(rawArtists)
+ val other = _artists[newIdx]
+ _artists[newIdx] = _artists[i]
+ _artists[i] = other
+ }
+
+ check(_genres.isNotEmpty()) { "Malformed song: No genres" }
+ for (i in _genres.indices) {
+ // Non-destructively reorder the linked genres so that they align with
+ // the genre ordering within the song metadata.
+ val newIdx = _genres[i].getOriginalPositionIn(rawGenres)
+ val other = _genres[newIdx]
+ _genres[newIdx] = _genres[i]
+ _genres[i] = other
+ }
+ return this
+ }
+
+ /** Raw information about a [RealSong] obtained from the filesystem/Extractor instances. */
+ class Raw
+ constructor(
+ /**
+ * 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 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 {
+ 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) }
+ ?: 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 })
+ }
+ override val rawName = raw.name
+ override val rawSortName = raw.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 durationMs: Long
+ override val dateAdded: Long
+
+ // Note: Append song contents to MusicParent equality so that Groups with
+ // the same UID but different contents are not equal.
+ override fun hashCode() = 31 * uid.hashCode() + songs.hashCode()
+ override fun equals(other: Any?) = other is Album && uid == other.uid && songs == other.songs
+
+ private val _artists = mutableListOf()
+ override val artists: List
+ get() = _artists
+ override fun resolveArtistContents(context: Context) = resolveNames(context, artists)
+ override fun areArtistContentsTheSame(other: Album): Boolean {
+ for (i in 0 until max(artists.size, other.artists.size)) {
+ val a = artists.getOrNull(i) ?: return false
+ val b = other.artists.getOrNull(i) ?: return false
+ if (a.rawName != b.rawName) {
+ return false
+ }
+ }
+
+ return true
+ }
+ init {
+ var totalDuration: Long = 0
+ var earliestDateAdded: Long = Long.MAX_VALUE
+
+ // Do linking and value generation in the same loop for efficiency.
+ for (song in songs) {
+ song.link(this)
+ if (song.dateAdded < earliestDateAdded) {
+ earliestDateAdded = song.dateAdded
+ }
+ totalDuration += song.durationMs
+ }
+
+ durationMs = totalDuration
+ dateAdded = earliestDateAdded
+ }
+
+ /**
+ * 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].
+ */
+ val rawArtists = raw.rawArtists
+
+ /**
+ * Links this [RealAlbum] with a parent [RealArtist].
+ * @param artist The parent [RealArtist] to link to.
+ */
+ fun link(artist: RealArtist) {
+ _artists.add(artist)
+ }
+
+ /**
+ * Perform final validation and organization on this instance.
+ * @return This instance upcasted to [Album].
+ */
+ fun finalize(): Album {
+ check(songs.isNotEmpty()) { "Malformed album: Empty" }
+ check(_artists.isNotEmpty()) { "Malformed album: No artists" }
+ for (i in _artists.indices) {
+ // Non-destructively reorder the linked artists so that they align with
+ // the artist ordering within the song metadata.
+ val newIdx = _artists[i].getOriginalPositionIn(rawArtists)
+ val other = _artists[newIdx]
+ _artists[newIdx] = _artists[i]
+ _artists[i] = other
+ }
+ 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 Artist.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 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 constructor(private val raw: Raw, 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
+ override val collationKey = makeCollationKey(this)
+ override fun resolveName(context: Context) = rawName ?: context.getString(R.string.def_artist)
+ override val songs: List
+
+ override val albums: List
+ override val durationMs: Long?
+ override val isCollaborator: Boolean
+
+ // Note: Append song contents to MusicParent equality so that Groups with
+ // the same UID but different contents are not equal.
+ override fun hashCode() = 31 * uid.hashCode() + songs.hashCode()
+ override fun equals(other: Any?) = other is Album && uid == other.uid && songs == other.songs
+
+ override lateinit var genres: List
+ override fun resolveGenreContents(context: Context) = resolveNames(context, genres)
+ override fun areGenreContentsTheSame(other: Artist): Boolean {
+ for (i in 0 until max(genres.size, other.genres.size)) {
+ val a = genres.getOrNull(i) ?: return false
+ val b = other.genres.getOrNull(i) ?: return false
+ if (a.rawName != b.rawName) {
+ return false
+ }
+ }
+
+ return true
+ }
+
+ init {
+ val distinctSongs = mutableSetOf()
+ val distinctAlbums = mutableSetOf()
+
+ var noAlbums = true
+
+ for (music in songAlbums) {
+ when (music) {
+ is RealSong -> {
+ music.link(this)
+ distinctSongs.add(music)
+ distinctAlbums.add(music.album)
+ }
+ is RealAlbum -> {
+ music.link(this)
+ distinctAlbums.add(music)
+ noAlbums = false
+ }
+ else -> error("Unexpected input music ${music::class.simpleName}")
+ }
+ }
+
+ songs = distinctSongs.toList()
+ albums = distinctAlbums.toList()
+ durationMs = songs.sumOf { it.durationMs }.nonZeroOrNull()
+ isCollaborator = noAlbums
+ }
+
+ /**
+ * 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.
+ */
+ fun getOriginalPositionIn(rawArtists: List) = rawArtists.indexOf(raw)
+
+ /**
+ * Perform final validation and organization on this instance.
+ * @return This instance upcasted to [Artist].
+ */
+ fun finalize(): Artist {
+ check(songs.isNotEmpty() || albums.isNotEmpty()) { "Malformed artist: Empty" }
+ genres =
+ Sort(Sort.Mode.ByName, true)
+ .genres(songs.flatMapTo(mutableSetOf()) { it.genres })
+ .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 constructor(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
+ override val rawSortName = rawName
+ override val collationKey = makeCollationKey(this)
+ override fun resolveName(context: Context) = rawName ?: context.getString(R.string.def_genre)
+
+ override val albums: List
+ override val artists: List
+ override val durationMs: Long
+
+ // Note: Append song contents to MusicParent equality so that Groups with
+ // the same UID but different contents are not equal.
+ override fun hashCode() = 31 * uid.hashCode() + songs.hashCode()
+ override fun equals(other: Any?) = other is Album && uid == other.uid && songs == other.songs
+
+ init {
+ val distinctAlbums = mutableSetOf()
+ val distinctArtists = mutableSetOf()
+ var totalDuration = 0L
+
+ for (song in songs) {
+ song.link(this)
+ distinctAlbums.add(song.album)
+ distinctArtists.addAll(song.artists)
+ totalDuration += song.durationMs
+ }
+
+ albums =
+ Sort(Sort.Mode.ByName, true).albums(distinctAlbums).sortedByDescending { album ->
+ album.songs.count { it.genres.contains(this) }
+ }
+ artists = Sort(Sort.Mode.ByName, true).artists(distinctArtists)
+ durationMs = totalDuration
+ }
+
+ /**
+ * 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.
+ */
+ fun getOriginalPositionIn(rawGenres: List) = rawGenres.indexOf(raw)
+
+ /**
+ * Perform final validation and organization on this instance.
+ * @return This instance upcasted to [Genre].
+ */
+ fun finalize(): Music {
+ 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
+ }
+ }
+}
+
+/**
+ * Update a [MessageDigest] with a lowercase [String].
+ * @param string The [String] to hash. If null, it will not be hashed.
+ */
+@VisibleForTesting
+fun MessageDigest.update(string: String?) {
+ if (string != null) {
+ update(string.lowercase().toByteArray())
+ } else {
+ update(0)
+ }
+}
+
+/**
+ * Update a [MessageDigest] with the string representation of a [Date].
+ * @param date The [Date] to hash. If null, nothing will be done.
+ */
+@VisibleForTesting
+fun MessageDigest.update(date: Date?) {
+ if (date != null) {
+ update(date.toString().toByteArray())
+ } else {
+ update(0)
+ }
+}
+
+/**
+ * Update a [MessageDigest] with the lowercase versions of all of the input [String]s.
+ * @param strings The [String]s to hash. If a [String] is null, it will not be hashed.
+ */
+@VisibleForTesting
+fun MessageDigest.update(strings: List) {
+ strings.forEach(::update)
+}
+
+/**
+ * Update a [MessageDigest] with the little-endian bytes of a [Int].
+ * @param n The [Int] to write. If null, nothing will be done.
+ */
+@VisibleForTesting
+fun MessageDigest.update(n: Int?) {
+ if (n != null) {
+ update(byteArrayOf(n.toByte(), n.shr(8).toByte(), n.shr(16).toByte(), n.shr(24).toByte()))
+ } else {
+ update(0)
+ }
+}
+
+/** Cached collator instance re-used with [makeCollationKey]. */
+private val COLLATOR: Collator = Collator.getInstance().apply { strength = Collator.PRIMARY }
+
+/**
+ * Provided implementation to create a [CollationKey] in the way described by [collationKey]. This
+ * should be used in all overrides of all [CollationKey].
+ * @param music The [Music] to create the [CollationKey] for.
+ * @return A [CollationKey] that follows the specification described by [collationKey].
+ */
+private fun makeCollationKey(music: Music): CollationKey? {
+ val sortName =
+ (music.rawSortName ?: music.rawName)?.run {
+ when {
+ length > 5 && startsWith("the ", ignoreCase = true) -> substring(4)
+ length > 4 && startsWith("an ", ignoreCase = true) -> substring(3)
+ length > 3 && startsWith("a ", ignoreCase = true) -> substring(2)
+ else -> this
+ }
+ }
+
+ return COLLATOR.getCollationKey(sortName)
+}
+
+/**
+ * Join a list of [Music]'s resolved names into a string in a localized manner, using
+ * [R.string.fmt_list].
+ * @param context [Context] required to obtain localized formatting.
+ * @param values The list of [Music] to format.
+ * @return A single string consisting of the values delimited by a localized separator.
+ */
+private fun resolveNames(context: Context, values: List): String {
+ if (values.isEmpty()) {
+ // Nothing to do.
+ return ""
+ }
+
+ var joined = values.first().resolveName(context)
+ for (i in 1..values.lastIndex) {
+ // Chain all previous values with the next value in the list with another delimiter.
+ joined = context.getString(R.string.fmt_list, joined, values[i].resolveName(context))
+ }
+ return joined
+}
diff --git a/app/src/main/java/org/oxycblt/auxio/music/extractor/CacheExtractor.kt b/app/src/main/java/org/oxycblt/auxio/music/extractor/CacheExtractor.kt
index 22b69e887..e919d5f42 100644
--- a/app/src/main/java/org/oxycblt/auxio/music/extractor/CacheExtractor.kt
+++ b/app/src/main/java/org/oxycblt/auxio/music/extractor/CacheExtractor.kt
@@ -28,6 +28,7 @@ import androidx.room.Room
import androidx.room.RoomDatabase
import androidx.room.TypeConverter
import androidx.room.TypeConverters
+import org.oxycblt.auxio.music.RealSong
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.music.format.Date
import org.oxycblt.auxio.music.parsing.correctWhitespace
@@ -45,20 +46,20 @@ interface CacheExtractor {
suspend fun init()
/**
- * Finalize the Extractor by writing the newly-loaded [Song.Raw]s back into the cache, alongside
- * freeing up memory.
+ * Finalize the Extractor by writing the newly-loaded [RealSong.Raw]s back into the cache,
+ * alongside freeing up memory.
* @param rawSongs The songs to write into the cache.
*/
- suspend fun finalize(rawSongs: List)
+ suspend fun finalize(rawSongs: List)
/**
- * Use the cache to populate the given [Song.Raw].
- * @param rawSong The [Song.Raw] to attempt to populate. Note that this [Song.Raw] will only
- * contain the bare minimum information required to load a cache entry.
+ * Use the cache to populate the given [RealSong.Raw].
+ * @param rawSong The [RealSong.Raw] to attempt to populate. Note that this [RealSong.Raw] will
+ * only contain the bare minimum information required to load a cache entry.
* @return An [ExtractionResult] representing the result of the operation.
* [ExtractionResult.PARSED] is not returned.
*/
- fun populate(rawSong: Song.Raw): ExtractionResult
+ fun populate(rawSong: RealSong.Raw): ExtractionResult
companion object {
/**
@@ -90,7 +91,7 @@ private open class WriteOnlyCacheExtractor(private val context: Context) : Cache
// Nothing to do.
}
- override suspend fun finalize(rawSongs: List) {
+ override suspend fun finalize(rawSongs: List) {
try {
// Still write out whatever data was extracted.
cacheDao.nukeCache()
@@ -101,7 +102,7 @@ private open class WriteOnlyCacheExtractor(private val context: Context) : Cache
}
}
- override fun populate(rawSong: Song.Raw) =
+ override fun populate(rawSong: RealSong.Raw) =
// Nothing to do.
ExtractionResult.NONE
}
@@ -133,7 +134,7 @@ private class ReadWriteCacheExtractor(private val context: Context) :
}
}
- override suspend fun finalize(rawSongs: List) {
+ override suspend fun finalize(rawSongs: List) {
cacheMap = null
// Same some time by not re-writing the cache if we were able to create the entire
// library from it. If there is even just one song we could not populate from the
@@ -144,7 +145,7 @@ private class ReadWriteCacheExtractor(private val context: Context) :
}
}
- override fun populate(rawSong: Song.Raw): ExtractionResult {
+ override fun populate(rawSong: RealSong.Raw): ExtractionResult {
val map = cacheMap ?: return ExtractionResult.NONE
// For a cached raw song to be used, it must exist within the cache and have matching
@@ -260,7 +261,7 @@ private data class CachedSong(
/** @see Genre.Raw.name */
var genreNames: List = listOf()
) {
- fun copyToRaw(rawSong: Song.Raw): CachedSong {
+ fun copyToRaw(rawSong: RealSong.Raw): CachedSong {
rawSong.musicBrainzId = musicBrainzId
rawSong.name = name
rawSong.sortName = sortName
@@ -305,7 +306,7 @@ private data class CachedSong(
companion object {
const val TABLE_NAME = "cached_songs"
- fun fromRaw(rawSong: Song.Raw) =
+ fun fromRaw(rawSong: RealSong.Raw) =
CachedSong(
mediaStoreId =
requireNotNull(rawSong.mediaStoreId) { "Invalid raw: No MediaStore ID" },
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 f67dc5b23..dfaa59307 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
@@ -28,7 +28,7 @@ import androidx.core.database.getIntOrNull
import androidx.core.database.getStringOrNull
import java.io.File
import org.oxycblt.auxio.music.MusicSettings
-import org.oxycblt.auxio.music.Song
+import org.oxycblt.auxio.music.RealSong
import org.oxycblt.auxio.music.format.Date
import org.oxycblt.auxio.music.parsing.parseId3v2PositionField
import org.oxycblt.auxio.music.parsing.transformPositionField
@@ -191,11 +191,11 @@ abstract class MediaStoreExtractor(
}
/**
- * Finalize the Extractor by writing the newly-loaded [Song.Raw]s back into the cache, alongside
- * freeing up memory.
+ * Finalize the Extractor by writing the newly-loaded [RealSong.Raw]s back into the cache,
+ * alongside freeing up memory.
* @param rawSongs The songs to write into the cache.
*/
- open suspend fun finalize(rawSongs: List) {
+ open suspend fun finalize(rawSongs: List) {
// Free the cursor (and it's resources)
cursor?.close()
cursor = null
@@ -203,12 +203,12 @@ abstract class MediaStoreExtractor(
}
/**
- * Populate a [Song.Raw] with the next [Cursor] value provided by [MediaStore].
- * @param raw The [Song.Raw] to populate.
+ * Populate a [RealSong.Raw] with the next [Cursor] value provided by [MediaStore].
+ * @param raw The [RealSong.Raw] to populate.
* @return An [ExtractionResult] signifying the result of the operation. Will return
* [ExtractionResult.CACHED] if [CacheExtractor] returned it.
*/
- fun populate(raw: Song.Raw): ExtractionResult {
+ fun populate(raw: RealSong.Raw): ExtractionResult {
val cursor = requireNotNull(cursor) { "MediaStoreLayer is not properly initialized" }
// Move to the next cursor, stopping if we have exhausted it.
if (!cursor.moveToNext()) {
@@ -268,15 +268,15 @@ abstract class MediaStoreExtractor(
protected abstract fun addDirToSelector(dir: Directory, args: MutableList): Boolean
/**
- * Populate a [Song.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
+ * 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.
* @param cursor The [Cursor] to read from.
- * @param raw The [Song.Raw] to populate.
+ * @param raw The [RealSong.Raw] to populate.
* @see populateMetadata
*/
- protected open fun populateFileData(cursor: Cursor, raw: Song.Raw) {
+ protected open fun populateFileData(cursor: Cursor, raw: RealSong.Raw) {
raw.mediaStoreId = cursor.getLong(idIndex)
raw.dateAdded = cursor.getLong(dateAddedIndex)
raw.dateModified = cursor.getLong(dateAddedIndex)
@@ -288,14 +288,14 @@ abstract class MediaStoreExtractor(
}
/**
- * Populate a [Song.Raw] with the Metadata of the given [MediaStore] [Cursor], which is the data
- * about a [Song.Raw] that can be cached. This includes any information intrinsic to the file or
- * it's file format, such as music tags.
+ * 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.
* @param cursor The [Cursor] to read from.
- * @param raw The [Song.Raw] to populate.
+ * @param raw The [RealSong.Raw] to populate.
* @see populateFileData
*/
- protected open fun populateMetadata(cursor: Cursor, raw: Song.Raw) {
+ protected open fun populateMetadata(cursor: Cursor, raw: RealSong.Raw) {
// Song title
raw.name = cursor.getString(titleIndex)
// Size (in bytes)
@@ -408,7 +408,7 @@ private class Api21MediaStoreExtractor(context: Context, cacheExtractor: CacheEx
return true
}
- override fun populateFileData(cursor: Cursor, raw: Song.Raw) {
+ override fun populateFileData(cursor: Cursor, raw: RealSong.Raw) {
super.populateFileData(cursor, raw)
val data = cursor.getString(dataIndex)
@@ -434,7 +434,7 @@ private class Api21MediaStoreExtractor(context: Context, cacheExtractor: CacheEx
}
}
- override fun populateMetadata(cursor: Cursor, raw: Song.Raw) {
+ override fun populateMetadata(cursor: Cursor, raw: RealSong.Raw) {
super.populateMetadata(cursor, raw)
// See unpackTrackNo/unpackDiscNo for an explanation
// of how this column is set up.
@@ -495,7 +495,7 @@ private open class BaseApi29MediaStoreExtractor(context: Context, cacheExtractor
return true
}
- override fun populateFileData(cursor: Cursor, raw: Song.Raw) {
+ override fun populateFileData(cursor: Cursor, raw: RealSong.Raw) {
super.populateFileData(cursor, raw)
// Find the StorageVolume whose MediaStore name corresponds to this song.
// This is combined with the plain relative path column to create the directory.
@@ -530,7 +530,7 @@ private open class Api29MediaStoreExtractor(context: Context, cacheExtractor: Ca
override val projection: Array
get() = super.projection + arrayOf(MediaStore.Audio.AudioColumns.TRACK)
- override fun populateMetadata(cursor: Cursor, raw: Song.Raw) {
+ override fun populateMetadata(cursor: Cursor, raw: RealSong.Raw) {
super.populateMetadata(cursor, raw)
// This extractor is volume-aware, but does not support the modern track columns.
// Use the old column instead. See unpackTrackNo/unpackDiscNo for an explanation
@@ -573,7 +573,7 @@ private class Api30MediaStoreExtractor(context: Context, cacheExtractor: CacheEx
MediaStore.Audio.AudioColumns.CD_TRACK_NUMBER,
MediaStore.Audio.AudioColumns.DISC_NUMBER)
- override fun populateMetadata(cursor: Cursor, raw: Song.Raw) {
+ override fun populateMetadata(cursor: Cursor, raw: RealSong.Raw) {
super.populateMetadata(cursor, raw)
// 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
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 fc03351b7..9df8f6e5a 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
@@ -22,7 +22,7 @@ import androidx.core.text.isDigitsOnly
import com.google.android.exoplayer2.MediaItem
import com.google.android.exoplayer2.MetadataRetriever
import kotlinx.coroutines.flow.flow
-import org.oxycblt.auxio.music.Song
+import org.oxycblt.auxio.music.RealSong
import org.oxycblt.auxio.music.format.Date
import org.oxycblt.auxio.music.format.TextTags
import org.oxycblt.auxio.music.parsing.parseId3v2PositionField
@@ -57,20 +57,20 @@ class MetadataExtractor(
suspend fun init() = mediaStoreExtractor.init().count
/**
- * Finalize the Extractor by writing the newly-loaded [Song.Raw]s back into the cache, alongside
- * freeing up memory.
+ * Finalize the Extractor by writing the newly-loaded [RealSong.Raw]s back into the cache,
+ * alongside freeing up memory.
* @param rawSongs The songs to write into the cache.
*/
- suspend fun finalize(rawSongs: List) = mediaStoreExtractor.finalize(rawSongs)
+ suspend fun finalize(rawSongs: List) = mediaStoreExtractor.finalize(rawSongs)
/**
- * Returns a flow that parses all [Song.Raw] instances queued by the sub-extractors. This will
- * first delegate to the sub-extractors before parsing the metadata itself.
- * @return A flow of [Song.Raw] instances.
+ * Returns a flow that parses all [RealSong.Raw] instances queued by the sub-extractors. This
+ * will first delegate to the sub-extractors before parsing the metadata itself.
+ * @return A flow of [RealSong.Raw] instances.
*/
fun extract() = flow {
while (true) {
- val raw = Song.Raw()
+ val raw = RealSong.Raw()
when (mediaStoreExtractor.populate(raw)) {
ExtractionResult.NONE -> break
ExtractionResult.PARSED -> {}
@@ -122,12 +122,12 @@ class MetadataExtractor(
}
/**
- * Wraps a [MetadataExtractor] future and processes it into a [Song.Raw] when completed.
+ * Wraps a [MetadataExtractor] future and processes it into a [RealSong.Raw] when completed.
* @param context [Context] required to open the audio file.
- * @param raw [Song.Raw] to process.
+ * @param raw [RealSong.Raw] to process.
* @author Alexander Capehart (OxygenCobalt)
*/
-class Task(context: Context, private val raw: Song.Raw) {
+class Task(context: Context, private val raw: RealSong.Raw) {
// 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.
@@ -139,9 +139,9 @@ class Task(context: Context, private val raw: Song.Raw) {
/**
* Try to get a completed song from this [Task], if it has finished processing.
- * @return A [Song.Raw] instance if processing has completed, null otherwise.
+ * @return A [RealSong.Raw] instance if processing has completed, null otherwise.
*/
- fun get(): Song.Raw? {
+ fun get(): RealSong.Raw? {
if (!future.isDone) {
// Not done yet, nothing to do.
return null
@@ -173,7 +173,7 @@ class Task(context: Context, private val raw: Song.Raw) {
}
/**
- * Complete this instance's [Song.Raw] with ID3v2 Text Identification Frames.
+ * Complete this instance's [RealSong.Raw] with ID3v2 Text Identification Frames.
* @param textFrames A mapping between ID3v2 Text Identification Frame IDs and one or more
* values.
*/
@@ -272,7 +272,7 @@ class Task(context: Context, private val raw: Song.Raw) {
}
/**
- * Complete this instance's [Song.Raw] with Vorbis comments.
+ * Complete this instance's [RealSong.Raw] with Vorbis comments.
* @param comments A mapping between vorbis comment names and one or more vorbis comment values.
*/
private fun populateWithVorbis(comments: Map>) {
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 33dcf2aa2..a49ab9d52 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
@@ -34,23 +34,70 @@ import org.oxycblt.auxio.util.logD
*
* @author Alexander Capehart
*/
-class Library(rawSongs: List, settings: MusicSettings) {
- /** All [Song]s that were detected on the device. */
- val songs = Sort(Sort.Mode.ByName, true).songs(rawSongs.map { Song(it, settings) }.distinct())
- /** All [Album]s found on the device. */
- val albums = buildAlbums(songs)
- /** All [Artist]s found on the device. */
- val artists = buildArtists(songs, albums)
- /** All [Genre]s found on the device. */
- val genres = buildGenres(songs)
+interface Library {
+ /** All [Song]s in this [Library]. */
+ val songs: List
+ /** All [Album]s in this [Library]. */
+ val albums: List
+ /** All [Artist]s in this [Library]. */
+ val artists: List
+ /** All [Genre]s in this [Library]. */
+ val genres: List
+
+ /**
+ * Finds a [Music] item [T] in the library by it's [Music.UID].
+ * @param uid The [Music.UID] to search for.
+ * @return The [T] corresponding to the given [Music.UID], or null if nothing could be found or
+ * the [Music.UID] did not correspond to a [T].
+ */
+ fun find(uid: Music.UID): T?
+
+ /**
+ * Convert a [Song] from an another library into a [Song] in this [Library].
+ * @param song The [Song] to convert.
+ * @return The analogous [Song] in this [Library], or null if it does not exist.
+ */
+ fun sanitize(song: Song): Song?
+
+ /**
+ * Convert a [MusicParent] from an another library into a [MusicParent] in this [Library].
+ * @param parent The [MusicParent] to convert.
+ * @return The analogous [Album] in this [Library], or null if it does not exist.
+ */
+ fun sanitize(parent: T): T?
+
+ /**
+ * Find a [Song] instance corresponding to the given Intent.ACTION_VIEW [Uri].
+ * @param context [Context] required to analyze the [Uri].
+ * @param uri [Uri] to search for.
+ * @return A [Song] corresponding to the given [Uri], or null if one could not be found.
+ */
+ fun findSongForUri(context: Context, uri: Uri): Song?
+
+ companion object {
+ /**
+ * Create a library-backed instance of [Library].
+ * @param rawSongs [RealSong.Raw]s to create the library out of.
+ * @param settings [MusicSettings] required.
+ */
+ fun from(rawSongs: List, settings: MusicSettings): Library =
+ RealLibrary(rawSongs, settings)
+ }
+}
+
+private class RealLibrary(rawSongs: List, settings: MusicSettings) : Library {
+ override val songs =
+ Sort(Sort.Mode.ByName, true).songs(rawSongs.map { RealSong(it, settings) }.distinct())
+ override val albums = buildAlbums(songs)
+ override val artists = buildArtists(songs, albums)
+ override val genres = buildGenres(songs)
// Use a mapping to make finding information based on it's UID much faster.
private val uidMap = buildMap {
- for (music in (songs + albums + artists + genres)) {
- // Finalize all music in the same mapping creation loop for efficiency.
- music._finalize()
- this[music.uid] = music
- }
+ songs.forEach { this[it.uid] = it.finalize() }
+ albums.forEach { this[it.uid] = it.finalize() }
+ artists.forEach { this[it.uid] = it.finalize() }
+ genres.forEach { this[it.uid] = it.finalize() }
}
/**
@@ -59,29 +106,13 @@ class Library(rawSongs: List, settings: MusicSettings) {
* @return The [T] corresponding to the given [Music.UID], or null if nothing could be found or
* the [Music.UID] did not correspond to a [T].
*/
- @Suppress("UNCHECKED_CAST") fun find(uid: Music.UID) = uidMap[uid] as? T
+ @Suppress("UNCHECKED_CAST") override fun find(uid: Music.UID) = uidMap[uid] as? T
- /**
- * Convert a [Song] from an another library into a [Song] in this [Library].
- * @param song The [Song] to convert.
- * @return The analogous [Song] in this [Library], or null if it does not exist.
- */
- fun sanitize(song: Song) = find(song.uid)
+ override fun sanitize(song: Song) = find(song.uid)
- /**
- * Convert a [MusicParent] from an another library into a [MusicParent] in this [Library].
- * @param parent The [MusicParent] to convert.
- * @return The analogous [Album] in this [Library], or null if it does not exist.
- */
- fun sanitize(parent: T) = find(parent.uid)
+ override fun sanitize(parent: T) = find(parent.uid)
- /**
- * Find a [Song] instance corresponding to the given Intent.ACTION_VIEW [Uri].
- * @param context [Context] required to analyze the [Uri].
- * @param uri [Uri] to search for.
- * @return A [Song] corresponding to the given [Uri], or null if one could not be found.
- */
- fun findSongForUri(context: Context, uri: Uri) =
+ override fun findSongForUri(context: Context, uri: Uri) =
context.contentResolverSafe.useQuery(
uri, arrayOf(OpenableColumns.DISPLAY_NAME, OpenableColumns.SIZE)) { cursor ->
cursor.moveToFirst()
@@ -100,11 +131,11 @@ class Library(rawSongs: List, settings: MusicSettings) {
* @return A non-empty list of [Album]s. These [Album]s will be incomplete and must be linked
* with parent [Artist] instances in order to be usable.
*/
- private fun buildAlbums(songs: List): List {
+ private fun buildAlbums(songs: List): List {
// Group songs by their singular raw album, then map the raw instances and their
// grouped songs to Album values. Album.Raw will handle the actual grouping rules.
- val songsByAlbum = songs.groupBy { it._rawAlbum }
- val albums = songsByAlbum.map { Album(it.key, it.value) }
+ val songsByAlbum = songs.groupBy { it.rawAlbum }
+ val albums = songsByAlbum.map { RealAlbum(it.key, it.value) }
logD("Successfully built ${albums.size} albums")
return albums
}
@@ -122,25 +153,25 @@ class Library(rawSongs: List, settings: MusicSettings) {
* @return A non-empty list of [Artist]s. These [Artist]s will consist of the combined groupings
* of [Song]s and [Album]s.
*/
- private fun buildArtists(songs: List, albums: List): List {
+ 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) {
+ for (rawArtist in song.rawArtists) {
musicByArtist.getOrPut(rawArtist) { mutableListOf() }.add(song)
}
}
for (album in albums) {
- for (rawArtist in album._rawArtists) {
+ for (rawArtist in album.rawArtists) {
musicByArtist.getOrPut(rawArtist) { mutableListOf() }.add(album)
}
}
// Convert the combined mapping into artist instances.
- val artists = musicByArtist.map { Artist(it.key, it.value) }
+ val artists = musicByArtist.map { RealArtist(it.key, it.value) }
logD("Successfully built ${artists.size} artists")
return artists
}
@@ -152,18 +183,18 @@ class Library(rawSongs: List, settings: MusicSettings) {
* created.
* @return A non-empty list of [Genre]s.
*/
- private fun buildGenres(songs: List): List {
+ 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) {
+ for (rawGenre in song.rawGenres) {
songsByGenre.getOrPut(rawGenre) { mutableListOf() }.add(song)
}
}
// Convert the mapping into genre instances.
- val genres = songsByGenre.map { Genre(it.key, it.value) }
+ val genres = songsByGenre.map { RealGenre(it.key, it.value) }
logD("Successfully built ${genres.size} genres")
return genres
}
diff --git a/app/src/main/java/org/oxycblt/auxio/music/library/Sort.kt b/app/src/main/java/org/oxycblt/auxio/music/library/Sort.kt
index 32c02c53d..9a6e6d3ba 100644
--- a/app/src/main/java/org/oxycblt/auxio/music/library/Sort.kt
+++ b/app/src/main/java/org/oxycblt/auxio/music/library/Sort.kt
@@ -55,7 +55,7 @@ data class Sort(val mode: Mode, val isAscending: Boolean) {
* @param songs The list of [Song]s.
* @return A new list of [Song]s sorted by this [Sort]'s configuration.
*/
- fun songs(songs: Collection): List {
+ fun songs(songs: Collection): List {
val mutable = songs.toMutableList()
songsInPlace(mutable)
return mutable
@@ -66,7 +66,7 @@ data class Sort(val mode: Mode, val isAscending: Boolean) {
* @param albums The list of [Album]s.
* @return A new list of [Album]s sorted by this [Sort]'s configuration.
*/
- fun albums(albums: Collection): List {
+ fun albums(albums: Collection): List {
val mutable = albums.toMutableList()
albumsInPlace(mutable)
return mutable
@@ -77,7 +77,7 @@ data class Sort(val mode: Mode, val isAscending: Boolean) {
* @param artists The list of [Artist]s.
* @return A new list of [Artist]s sorted by this [Sort]'s configuration.
*/
- fun artists(artists: Collection): List {
+ fun artists(artists: Collection): List {
val mutable = artists.toMutableList()
artistsInPlace(mutable)
return mutable
@@ -88,7 +88,7 @@ data class Sort(val mode: Mode, val isAscending: Boolean) {
* @param genres The list of [Genre]s.
* @return A new list of [Genre]s sorted by this [Sort]'s configuration.
*/
- fun genres(genres: Collection): List {
+ fun genres(genres: Collection): List {
val mutable = genres.toMutableList()
genresInPlace(mutable)
return mutable
@@ -98,7 +98,7 @@ data class Sort(val mode: Mode, val isAscending: Boolean) {
* Sort a *mutable* list of [Song]s in-place using this [Sort]'s configuration.
* @param songs The [Song]s to sort.
*/
- private fun songsInPlace(songs: MutableList) {
+ private fun songsInPlace(songs: MutableList) {
songs.sortWith(mode.getSongComparator(isAscending))
}
@@ -106,7 +106,7 @@ data class Sort(val mode: Mode, val isAscending: Boolean) {
* Sort a *mutable* list of [Album]s in-place using this [Sort]'s configuration.
* @param albums The [Album]s to sort.
*/
- private fun albumsInPlace(albums: MutableList) {
+ private fun albumsInPlace(albums: MutableList) {
albums.sortWith(mode.getAlbumComparator(isAscending))
}
@@ -114,7 +114,7 @@ data class Sort(val mode: Mode, val isAscending: Boolean) {
* Sort a *mutable* list of [Artist]s in-place using this [Sort]'s configuration.
* @param artists The [Album]s to sort.
*/
- private fun artistsInPlace(artists: MutableList) {
+ private fun artistsInPlace(artists: MutableList) {
artists.sortWith(mode.getArtistComparator(isAscending))
}
@@ -122,7 +122,7 @@ data class Sort(val mode: Mode, val isAscending: Boolean) {
* Sort a *mutable* list of [Genre]s in-place using this [Sort]'s configuration.
* @param genres The [Genre]s to sort.
*/
- private fun genresInPlace(genres: MutableList) {
+ private fun genresInPlace(genres: MutableList) {
genres.sortWith(mode.getGenreComparator(isAscending))
}
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 4c0c480d9..5053a4f38 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
@@ -337,12 +337,12 @@ private class RealIndexer : Indexer {
// Build the rest of the music library from the song list. This is much more powerful
// and reliable compared to using MediaStore to obtain grouping information.
val buildStart = System.currentTimeMillis()
- val library = Library(rawSongs, MusicSettings.from(context))
+ val library = Library.from(rawSongs, MusicSettings.from(context))
logD("Successfully built library in ${System.currentTimeMillis() - buildStart}ms")
return library
}
- private suspend fun loadRawSongs(metadataExtractor: MetadataExtractor): List {
+ private suspend fun loadRawSongs(metadataExtractor: MetadataExtractor): List {
logD("Starting indexing process")
val start = System.currentTimeMillis()
// Start initializing the extractors. Use an indeterminate state, as there is no ETA on
@@ -352,7 +352,7 @@ private class RealIndexer : Indexer {
yield()
// Note: We use a set here so we can eliminate song duplicates.
- val rawSongs = mutableListOf()
+ val rawSongs = mutableListOf()
metadataExtractor.extract().collect { rawSong ->
rawSongs.add(rawSong)
// Now we can signal a defined progress by showing how many songs we have
diff --git a/app/src/main/java/org/oxycblt/auxio/util/LangUtil.kt b/app/src/main/java/org/oxycblt/auxio/util/LangUtil.kt
index 6441f94ba..71b963b38 100644
--- a/app/src/main/java/org/oxycblt/auxio/util/LangUtil.kt
+++ b/app/src/main/java/org/oxycblt/auxio/util/LangUtil.kt
@@ -17,7 +17,7 @@
package org.oxycblt.auxio.util
-import android.os.Looper
+import java.util.UUID
import kotlin.reflect.KClass
import org.oxycblt.auxio.BuildConfig
@@ -83,13 +83,13 @@ fun lazyReflectedMethod(clazz: KClass<*>, method: String) = lazy {
}
/**
- * Assert that the execution is currently on a background thread. This is helpful for functions that
- * don't necessarily require suspend, but still want to ensure that they are being called with a
- * co-routine.
- * @throws IllegalStateException If the execution is not on a background thread.
+ * Convert a [String] to a [UUID].
+ * @return A [UUID] converted from the [String] value, or null if the value was not valid.
+ * @see UUID.fromString
*/
-fun requireBackgroundThread() {
- check(Looper.myLooper() != Looper.getMainLooper()) {
- "This operation must be ran on a background thread"
+fun String.toUuidOrNull(): UUID? =
+ try {
+ UUID.fromString(this)
+ } catch (e: IllegalArgumentException) {
+ null
}
-}