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 } -}