music: hide data impls

Hide the implementation of Song, Album, Artist, and Genre.

This should make most of the app signifigantly easier to test.
This commit is contained in:
Alexander Capehart 2023-01-29 17:32:11 -07:00
parent 41bc6f9dfc
commit bfb1033ed7
No known key found for this signature in database
GPG key ID: 37DBE3621FE9AD47
9 changed files with 927 additions and 848 deletions

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
@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<Music>): 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<Song>
// 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<Song>
}
/**
* 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<Artist>()
/**
* 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<Artist>
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<Genre>()
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<Genre>
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<String> = listOf(),
/** @see Artist.Raw.musicBrainzId */
var artistMusicBrainzIds: List<String> = listOf(),
/** @see Artist.Raw.name */
var artistNames: List<String> = listOf(),
/** @see Artist.Raw.sortName */
var artistSortNames: List<String> = listOf(),
/** @see Artist.Raw.musicBrainzId */
var albumArtistMusicBrainzIds: List<String> = listOf(),
/** @see Artist.Raw.name */
var albumArtistNames: List<String> = listOf(),
/** @see Artist.Raw.sortName */
var albumArtistSortNames: List<String> = listOf(),
/** @see Genre.Raw.name */
var genreNames: List<String> = 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<Song>) : 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<Artist>()
/**
* 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<Artist>
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<Artist.Raw>
) {
// Albums are grouped as follows:
// - If we have a MusicBrainz ID, only group by it. This allows different Albums with the
// same name to be differentiated, which is common in large libraries.
// - If we do not have a MusicBrainz ID, compare by the lowercase album name and lowercase
// artist name. This allows for case-insensitive artist/album grouping, which can be common
// for albums/artists that have different naming (ex. "RAMMSTEIN" vs. "Rammstein").
// Cache the hash-code for HashMap efficiency.
private val hashCode =
musicBrainzId?.hashCode() ?: (31 * name.lowercase().hashCode() + rawArtists.hashCode())
override fun hashCode() = hashCode
override fun equals(other: Any?) =
other is Raw &&
when {
musicBrainzId != null && other.musicBrainzId != null ->
musicBrainzId == other.musicBrainzId
musicBrainzId == null && other.musicBrainzId == null ->
name.equals(other.name, true) && rawArtists == other.rawArtists
else -> false
}
}
fun areArtistContentsTheSame(other: Album): Boolean
}
/**
@ -788,295 +343,48 @@ class Album constructor(raw: Raw, override val songs: List<Song>) : MusicParent(
* will be linked to this [Artist].
* @author Alexander Capehart (OxygenCobalt)
*/
class Artist constructor(private val raw: Raw, songAlbums: List<Music>) : 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<Song>
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<Album>
/**
* 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<Song>()
val distinctAlbums = mutableSetOf<Album>()
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<Genre>
/** The [Genre]s of this artist. */
val genres: List<Genre>
/**
* 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<Raw>) = 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) } }
fun areGenreContentsTheSame(other: Artist): Boolean
}
/**
* 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
}
}
}
/**
* A genre of [Song]s.
* A genre.
* @author Alexander Capehart (OxygenCobalt)
*/
class Genre constructor(private val raw: Raw, override val songs: List<Song>) : 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<Album>
/** The artists indirectly linked to by the [Artist]s of this [Genre]. */
val artists: List<Artist>
/** The total duration of the songs in this genre, in milliseconds. */
val durationMs: Long
init {
val distinctAlbums = mutableSetOf<Album>()
val distinctArtists = mutableSetOf<Artist>()
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<Raw>) = 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<String?>) {
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)
}
}

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
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<RealArtist>()
override val artists: List<Artist>
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<RealGenre>()
override val genres: List<Genre>
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<String> = listOf(),
/** @see RealArtist.Raw.musicBrainzId */
var artistMusicBrainzIds: List<String> = listOf(),
/** @see RealArtist.Raw.name */
var artistNames: List<String> = listOf(),
/** @see RealArtist.Raw.sortName */
var artistSortNames: List<String> = listOf(),
/** @see RealArtist.Raw.musicBrainzId */
var albumArtistMusicBrainzIds: List<String> = listOf(),
/** @see RealArtist.Raw.name */
var albumArtistNames: List<String> = listOf(),
/** @see RealArtist.Raw.sortName */
var albumArtistSortNames: List<String> = listOf(),
/** @see RealGenre.Raw.name */
var genreNames: List<String> = listOf()
)
}
/**
* Library-backed implementation of [RealAlbum].
* @param raw The [RealAlbum.Raw] to derive the member data from.
* @param songs The [RealSong]s that are a part of this [RealAlbum]. These items will be linked to
* this [RealAlbum].
* @author Alexander Capehart (OxygenCobalt)
*/
class RealAlbum(val raw: Raw, override val songs: List<RealSong>) : Album {
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<RealArtist>()
override val artists: List<Artist>
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<RealArtist.Raw>
) {
// Albums are grouped as follows:
// - If we have a MusicBrainz ID, only group by it. This allows different Albums with the
// same name to be differentiated, which is common in large libraries.
// - If we do not have a MusicBrainz ID, compare by the lowercase album name and lowercase
// artist name. This allows for case-insensitive artist/album grouping, which can be common
// for albums/artists that have different naming (ex. "RAMMSTEIN" vs. "Rammstein").
// Cache the hash-code for HashMap efficiency.
private val hashCode =
musicBrainzId?.hashCode() ?: (31 * name.lowercase().hashCode() + rawArtists.hashCode())
override fun hashCode() = hashCode
override fun equals(other: Any?) =
other is Raw &&
when {
musicBrainzId != null && other.musicBrainzId != null ->
musicBrainzId == other.musicBrainzId
musicBrainzId == null && other.musicBrainzId == null ->
name.equals(other.name, true) && rawArtists == other.rawArtists
else -> false
}
}
}
/**
* Library-backed implementation of [RealArtist].
* @param raw The [RealArtist.Raw] to derive the member data from.
* @param 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<Music>) : Artist {
override val uid =
// Attempt to use a MusicBrainz ID first before falling back to a hashed UID.
raw.musicBrainzId?.let { Music.UID.musicBrainz(MusicMode.ARTISTS, it) }
?: Music.UID.auxio(MusicMode.ARTISTS) { update(raw.name) }
override val rawName = raw.name
override val rawSortName = raw.sortName
override val collationKey = makeCollationKey(this)
override fun resolveName(context: Context) = rawName ?: context.getString(R.string.def_artist)
override val songs: List<Song>
override val albums: List<Album>
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<Genre>
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<Song>()
val distinctAlbums = mutableSetOf<Album>()
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<Raw>) = 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<RealSong>) : 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<Album>
override val artists: List<Artist>
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<Album>()
val distinctArtists = mutableSetOf<Artist>()
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<Raw>) = 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<String?>) {
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<Music>): 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
}

View file

@ -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<Song.Raw>)
suspend fun finalize(rawSongs: List<RealSong.Raw>)
/**
* 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<Song.Raw>) {
override suspend fun finalize(rawSongs: List<RealSong.Raw>) {
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<Song.Raw>) {
override suspend fun finalize(rawSongs: List<RealSong.Raw>) {
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<String> = 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" },

View file

@ -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<Song.Raw>) {
open suspend fun finalize(rawSongs: List<RealSong.Raw>) {
// 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<String>): 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<String>
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

View file

@ -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<Song.Raw>) = mediaStoreExtractor.finalize(rawSongs)
suspend fun finalize(rawSongs: List<RealSong.Raw>) = 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<String, List<String>>) {

View file

@ -34,23 +34,70 @@ import org.oxycblt.auxio.util.logD
*
* @author Alexander Capehart
*/
class Library(rawSongs: List<Song.Raw>, 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<Song>
/** All [Album]s in this [Library]. */
val albums: List<Album>
/** All [Artist]s in this [Library]. */
val artists: List<Artist>
/** All [Genre]s in this [Library]. */
val genres: List<Genre>
/**
* 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 <T : Music> 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 <T : MusicParent> 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<RealSong.Raw>, settings: MusicSettings): Library =
RealLibrary(rawSongs, settings)
}
}
private class RealLibrary(rawSongs: List<RealSong.Raw>, 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<Song.Raw>, 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 <T : Music> find(uid: Music.UID) = uidMap[uid] as? T
@Suppress("UNCHECKED_CAST") override fun <T : Music> 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>(song.uid)
override fun sanitize(song: Song) = find<Song>(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 <T : MusicParent> sanitize(parent: T) = find<T>(parent.uid)
override fun <T : MusicParent> sanitize(parent: T) = find<T>(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<Song.Raw>, 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<Song>): List<Album> {
private fun buildAlbums(songs: List<RealSong>): List<RealAlbum> {
// 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<Song.Raw>, 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<Song>, albums: List<Album>): List<Artist> {
private fun buildArtists(songs: List<RealSong>, albums: List<RealAlbum>): List<RealArtist> {
// Add every raw artist credited to each Song/Album to the grouping. This way,
// different multi-artist combinations are not treated as different artists.
val musicByArtist = mutableMapOf<Artist.Raw, MutableList<Music>>()
val musicByArtist = mutableMapOf<RealArtist.Raw, MutableList<Music>>()
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<Song.Raw>, settings: MusicSettings) {
* created.
* @return A non-empty list of [Genre]s.
*/
private fun buildGenres(songs: List<Song>): List<Genre> {
private fun buildGenres(songs: List<RealSong>): List<RealGenre> {
// Add every raw genre credited to each Song to the grouping. This way,
// different multi-genre combinations are not treated as different genres.
val songsByGenre = mutableMapOf<Genre.Raw, MutableList<Song>>()
val songsByGenre = mutableMapOf<RealGenre.Raw, MutableList<RealSong>>()
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
}

View file

@ -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<Song>): List<Song> {
fun <T : Song> songs(songs: Collection<T>): List<T> {
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<Album>): List<Album> {
fun <T : Album> albums(albums: Collection<T>): List<T> {
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<Artist>): List<Artist> {
fun <T : Artist> artists(artists: Collection<T>): List<T> {
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<Genre>): List<Genre> {
fun <T : Genre> genres(genres: Collection<T>): List<T> {
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<Song>) {
private fun songsInPlace(songs: MutableList<out Song>) {
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<Album>) {
private fun albumsInPlace(albums: MutableList<out Album>) {
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<Artist>) {
private fun artistsInPlace(artists: MutableList<out Artist>) {
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<Genre>) {
private fun genresInPlace(genres: MutableList<out Genre>) {
genres.sortWith(mode.getGenreComparator(isAscending))
}

View file

@ -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<Song.Raw> {
private suspend fun loadRawSongs(metadataExtractor: MetadataExtractor): List<RealSong.Raw> {
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<Song.Raw>()
val rawSongs = mutableListOf<RealSong.Raw>()
metadataExtractor.extract().collect { rawSong ->
rawSongs.add(rawSong)
// Now we can signal a defined progress by showing how many songs we have

View file

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