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 * 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 * 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/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
@file:Suppress("PropertyName", "FunctionName")
package org.oxycblt.auxio.music package org.oxycblt.auxio.music
import android.content.Context import android.content.Context
import android.net.Uri
import android.os.Parcelable import android.os.Parcelable
import androidx.annotation.VisibleForTesting
import java.security.MessageDigest import java.security.MessageDigest
import java.text.CollationKey import java.text.CollationKey
import java.text.Collator
import java.util.UUID import java.util.UUID
import kotlin.math.max
import kotlinx.parcelize.IgnoredOnParcel import kotlinx.parcelize.IgnoredOnParcel
import kotlinx.parcelize.Parcelize import kotlinx.parcelize.Parcelize
import org.oxycblt.auxio.R
import org.oxycblt.auxio.list.Item import org.oxycblt.auxio.list.Item
import org.oxycblt.auxio.music.format.Date import org.oxycblt.auxio.music.format.Date
import org.oxycblt.auxio.music.format.Disc import org.oxycblt.auxio.music.format.Disc
import org.oxycblt.auxio.music.format.ReleaseType import org.oxycblt.auxio.music.format.ReleaseType
import org.oxycblt.auxio.music.library.Sort import org.oxycblt.auxio.music.storage.MimeType
import org.oxycblt.auxio.music.parsing.parseId3GenreNames import org.oxycblt.auxio.music.storage.Path
import org.oxycblt.auxio.music.parsing.parseMultiValue import org.oxycblt.auxio.util.toUuidOrNull
import org.oxycblt.auxio.music.storage.*
import org.oxycblt.auxio.util.nonZeroOrNull
import org.oxycblt.auxio.util.unlikelyToBeNull
// --- MUSIC MODELS ---
/** /**
* Abstract music data. This contains universal information about all concrete music * Abstract music data. This contains universal information about all concrete music
* implementations, such as identification information and names. * implementations, such as identification information and names.
* @author Alexander Capehart (OxygenCobalt) * @author Alexander Capehart (OxygenCobalt)
*/ */
sealed class Music : Item { sealed interface Music : Item {
/** /**
* A unique identifier for this music item. * A unique identifier for this music item.
* @see UID * @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 * 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]. * 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 * 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 * @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. * 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 * 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. * 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. * 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 * 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 * - 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. * convention for sorting media. This is not internationalized.
*/ */
abstract val collationKey: CollationKey? 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
/** /**
* A unique identifier for a piece of music. * A unique identifier for a piece of music.
@ -193,6 +128,7 @@ sealed class Music : Item {
private enum class Format(val namespace: String) { private enum class Format(val namespace: String) {
/** @see auxio */ /** @see auxio */
AUXIO("org.oxycblt.auxio"), AUXIO("org.oxycblt.auxio"),
/** @see musicBrainz */ /** @see musicBrainz */
MUSICBRAINZ("org.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. * An abstract grouping of [Song]s and other [Music] data.
* @author Alexander Capehart (OxygenCobalt) * @author Alexander Capehart (OxygenCobalt)
*/ */
sealed class MusicParent : Music() { sealed interface MusicParent : Music {
/** The [Song]s in this this group. */ val songs: List<Song>
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
} }
/** /**
* A song. Perhaps the foundation of the entirety of Auxio. * A song.
* @param raw The [Song.Raw] to derive the member data from.
* @param musicSettings [MusicSettings] to perform further user-configured parsing.
* @author Alexander Capehart (OxygenCobalt) * @author Alexander Capehart (OxygenCobalt)
*/ */
class Song constructor(raw: Raw, musicSettings: MusicSettings) : Music() { interface Song : 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
/** The track number. Will be null if no valid track number was present in the metadata. */ /** 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. */ /** 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. */ /** 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 * 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. * 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 * The [Path] to this audio file. This is only intended for display, [uri] should be favored
* instead for accessing the audio file. * instead for accessing the audio file.
*/ */
val path = val path: Path
Path(
name = requireNotNull(raw.fileName) { "Invalid raw: No display name" },
parent = requireNotNull(raw.directory) { "Invalid raw: No parent directory" })
/** The [MimeType] of the audio file. Only intended for display. */ /** The [MimeType] of the audio file. Only intended for display. */
val mimeType = val mimeType: MimeType
MimeType(
fromExtension = requireNotNull(raw.extensionMimeType) { "Invalid raw: No mime type" },
fromFormat = null)
/** The size of the audio file, in bytes. */ /** 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. */ /** 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. */ /** 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" } val dateAdded: Long
private var _album: Album? = null
/** /**
* The parent [Album]. If the metadata did not specify an album, it's parent directory is used * The parent [Album]. If the metadata did not specify an album, it's parent directory is used
* instead. * instead.
*/ */
val album: Album 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 * 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 * [Artist] name was specified in the metadata. Unliked [Album], artists are prioritized for
* this field. * this field.
*/ */
val artists: List<Artist> val artists: List<Artist>
get() = _artists
/** /**
* Resolves one or more [Artist]s into a single piece of human-readable names. * Resolves one or more [Artist]s into a single piece of human-readable names.
* @param context [Context] required for [resolveName]. formatter. * @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 * 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. * compare surface-level names, and not [Music.UID]s.
* @param other The [Song] to compare to. * @param other The [Song] to compare to.
* @return True if the [Artist] displays are equal, false otherwise * @return True if the [Artist] displays are equal, false otherwise
*/ */
fun areArtistContentsTheSame(other: Song): Boolean { 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>()
/** /**
* The parent [Genre]s of this [Song]. Is often one, but there can be multiple if more than one * 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. * [Genre] name was specified in the metadata.
*/ */
val genres: List<Genre> val genres: List<Genre>
get() = _genres
/** /**
* Resolves one or more [Genre]s into a single piece human-readable names. * Resolves one or more [Genre]s into a single piece human-readable names.
* @param context [Context] required for [resolveName]. * @param context [Context] required for [resolveName].
*/ */
fun resolveGenreContents(context: Context) = resolveNames(context, genres) fun resolveGenreContents(context: Context): String
// --- 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()
)
} }
/** /**
* An abstract release group. While it may be called an album, it encompasses other types of * An abstract release group. While it may be called an album, it encompasses other types of
* releases like singles, EPs, and compilations. * 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) * @author Alexander Capehart (OxygenCobalt)
*/ */
class Album constructor(raw: Raw, override val songs: List<Song>) : MusicParent() { interface Album : 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
/** The [Date.Range] that [Song]s in the [Album] were released. */ /** 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 * The [ReleaseType] of this album, signifying the type of release it actually is. Defaults to
* [ReleaseType.Album]. * [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 * The URI to a MediaStore-provided album cover. These images will be fast to load, but at the
* cost of image quality. * cost of image quality.
*/ */
val coverUri = raw.mediaStoreId.toCoverUri() val coverUri: Uri
/** The duration of all songs in the album, in milliseconds. */ /** The duration of all songs in the album, in milliseconds. */
val durationMs: Long val durationMs: Long
/** The earliest date a song in this album was added, as a unix epoch timestamp. */ /** The earliest date a song in this album was added, as a unix epoch timestamp. */
val dateAdded: Long 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 * 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 * one [Artist] name was specified in the metadata of the [Song]'s. Unlike [Song], album artists
* are prioritized for this field. * are prioritized for this field.
*/ */
val artists: List<Artist> val artists: List<Artist>
get() = _artists
/** /**
* Resolves one or more [Artist]s into a single piece of human-readable names. * Resolves one or more [Artist]s into a single piece of human-readable names.
* @param context [Context] required for [resolveName]. * @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 * 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. * only compare surface-level names, and not [Music.UID]s.
* @param other The [Album] to compare to. * @param other The [Album] to compare to.
* @return True if the [Artist] displays are equal, false otherwise * @return True if the [Artist] displays are equal, false otherwise
*/ */
fun areArtistContentsTheSame(other: Album): Boolean { 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
}
}
} }
/** /**
@ -788,295 +343,48 @@ class Album constructor(raw: Raw, override val songs: List<Song>) : MusicParent(
* will be linked to this [Artist]. * will be linked to this [Artist].
* @author Alexander Capehart (OxygenCobalt) * @author Alexander Capehart (OxygenCobalt)
*/ */
class Artist constructor(private val raw: Raw, songAlbums: List<Music>) : MusicParent() { interface Artist : 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>
/** /**
* All of the [Album]s this artist is credited to. Note that any [Song] credited to this artist * 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 * will have it's [Album] considered to be "indirectly" linked to this [Artist], and thus
* included in this list. * included in this list.
*/ */
val albums: List<Album> val albums: List<Album>
/** /**
* The duration of all [Song]s in the artist, in milliseconds. Will be null if there are no * The duration of all [Song]s in the artist, in milliseconds. Will be null if there are no
* songs. * songs.
*/ */
val durationMs: Long? val durationMs: Long?
/** /**
* Whether this artist is considered a "collaborator", i.e it is not directly credited on any * Whether this artist is considered a "collaborator", i.e it is not directly credited on any
* [Album]. * [Album].
*/ */
val isCollaborator: Boolean val isCollaborator: Boolean
/** The [Genre]s of this artist. */
init { val genres: List<Genre>
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>
/** /**
* Resolves one or more [Genre]s into a single piece of human-readable names. * Resolves one or more [Genre]s into a single piece of human-readable names.
* @param context [Context] required for [resolveName]. * @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 * 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. * only compare surface-level names, and not [Music.UID]s.
* @param other The [Artist] to compare to. * @param other The [Artist] to compare to.
* @return True if the [Genre] displays are equal, false otherwise * @return True if the [Genre] displays are equal, false otherwise
*/ */
fun areGenreContentsTheSame(other: Artist): Boolean { 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) } }
}
/**
* 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) * @author Alexander Capehart (OxygenCobalt)
*/ */
class Genre constructor(private val raw: Raw, override val songs: List<Song>) : MusicParent() { interface Genre : 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)
/** The albums indirectly linked to by the [Song]s of this [Genre]. */ /** The albums indirectly linked to by the [Song]s of this [Genre]. */
val albums: List<Album> val albums: List<Album>
/** The artists indirectly linked to by the [Artist]s of this [Genre]. */ /** The artists indirectly linked to by the [Artist]s of this [Genre]. */
val artists: List<Artist> val artists: List<Artist>
/** The total duration of the songs in this genre, in milliseconds. */ /** The total duration of the songs in this genre, in milliseconds. */
val durationMs: Long 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.RoomDatabase
import androidx.room.TypeConverter import androidx.room.TypeConverter
import androidx.room.TypeConverters import androidx.room.TypeConverters
import org.oxycblt.auxio.music.RealSong
import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.music.format.Date import org.oxycblt.auxio.music.format.Date
import org.oxycblt.auxio.music.parsing.correctWhitespace import org.oxycblt.auxio.music.parsing.correctWhitespace
@ -45,20 +46,20 @@ interface CacheExtractor {
suspend fun init() suspend fun init()
/** /**
* Finalize the Extractor by writing the newly-loaded [Song.Raw]s back into the cache, alongside * Finalize the Extractor by writing the newly-loaded [RealSong.Raw]s back into the cache,
* freeing up memory. * alongside freeing up memory.
* @param rawSongs The songs to write into the cache. * @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]. * Use the cache to populate the given [RealSong.Raw].
* @param rawSong The [Song.Raw] to attempt to populate. Note that this [Song.Raw] will only * @param rawSong The [RealSong.Raw] to attempt to populate. Note that this [RealSong.Raw] will
* contain the bare minimum information required to load a cache entry. * only contain the bare minimum information required to load a cache entry.
* @return An [ExtractionResult] representing the result of the operation. * @return An [ExtractionResult] representing the result of the operation.
* [ExtractionResult.PARSED] is not returned. * [ExtractionResult.PARSED] is not returned.
*/ */
fun populate(rawSong: Song.Raw): ExtractionResult fun populate(rawSong: RealSong.Raw): ExtractionResult
companion object { companion object {
/** /**
@ -90,7 +91,7 @@ private open class WriteOnlyCacheExtractor(private val context: Context) : Cache
// Nothing to do. // Nothing to do.
} }
override suspend fun finalize(rawSongs: List<Song.Raw>) { override suspend fun finalize(rawSongs: List<RealSong.Raw>) {
try { try {
// Still write out whatever data was extracted. // Still write out whatever data was extracted.
cacheDao.nukeCache() 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. // Nothing to do.
ExtractionResult.NONE 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 cacheMap = null
// Same some time by not re-writing the cache if we were able to create the entire // 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 // 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 val map = cacheMap ?: return ExtractionResult.NONE
// For a cached raw song to be used, it must exist within the cache and have matching // For a cached raw song to be used, it must exist within the cache and have matching
@ -260,7 +261,7 @@ private data class CachedSong(
/** @see Genre.Raw.name */ /** @see Genre.Raw.name */
var genreNames: List<String> = listOf() var genreNames: List<String> = listOf()
) { ) {
fun copyToRaw(rawSong: Song.Raw): CachedSong { fun copyToRaw(rawSong: RealSong.Raw): CachedSong {
rawSong.musicBrainzId = musicBrainzId rawSong.musicBrainzId = musicBrainzId
rawSong.name = name rawSong.name = name
rawSong.sortName = sortName rawSong.sortName = sortName
@ -305,7 +306,7 @@ private data class CachedSong(
companion object { companion object {
const val TABLE_NAME = "cached_songs" const val TABLE_NAME = "cached_songs"
fun fromRaw(rawSong: Song.Raw) = fun fromRaw(rawSong: RealSong.Raw) =
CachedSong( CachedSong(
mediaStoreId = mediaStoreId =
requireNotNull(rawSong.mediaStoreId) { "Invalid raw: No MediaStore ID" }, 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 androidx.core.database.getStringOrNull
import java.io.File import java.io.File
import org.oxycblt.auxio.music.MusicSettings 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.format.Date
import org.oxycblt.auxio.music.parsing.parseId3v2PositionField import org.oxycblt.auxio.music.parsing.parseId3v2PositionField
import org.oxycblt.auxio.music.parsing.transformPositionField 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 * Finalize the Extractor by writing the newly-loaded [RealSong.Raw]s back into the cache,
* freeing up memory. * alongside freeing up memory.
* @param rawSongs The songs to write into the cache. * @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) // Free the cursor (and it's resources)
cursor?.close() cursor?.close()
cursor = null cursor = null
@ -203,12 +203,12 @@ abstract class MediaStoreExtractor(
} }
/** /**
* Populate a [Song.Raw] with the next [Cursor] value provided by [MediaStore]. * Populate a [RealSong.Raw] with the next [Cursor] value provided by [MediaStore].
* @param raw The [Song.Raw] to populate. * @param raw The [RealSong.Raw] to populate.
* @return An [ExtractionResult] signifying the result of the operation. Will return * @return An [ExtractionResult] signifying the result of the operation. Will return
* [ExtractionResult.CACHED] if [CacheExtractor] returned it. * [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" } val cursor = requireNotNull(cursor) { "MediaStoreLayer is not properly initialized" }
// Move to the next cursor, stopping if we have exhausted it. // Move to the next cursor, stopping if we have exhausted it.
if (!cursor.moveToNext()) { if (!cursor.moveToNext()) {
@ -268,15 +268,15 @@ abstract class MediaStoreExtractor(
protected abstract fun addDirToSelector(dir: Directory, args: MutableList<String>): Boolean 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 * Populate a [RealSong.Raw] with the "File Data" of the given [MediaStore] [Cursor], which is
* data that cannot be cached. This includes any information not intrinsic to the file and * 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 * instead dependent on the file-system, which could change without invalidating the cache due
* to volume additions or removals. * to volume additions or removals.
* @param cursor The [Cursor] to read from. * @param cursor The [Cursor] to read from.
* @param raw The [Song.Raw] to populate. * @param raw The [RealSong.Raw] to populate.
* @see populateMetadata * @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.mediaStoreId = cursor.getLong(idIndex)
raw.dateAdded = cursor.getLong(dateAddedIndex) raw.dateAdded = cursor.getLong(dateAddedIndex)
raw.dateModified = 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 * Populate a [RealSong.Raw] with the Metadata of the given [MediaStore] [Cursor], which is the
* about a [Song.Raw] that can be cached. This includes any information intrinsic to the file or * data about a [RealSong.Raw] that can be cached. This includes any information intrinsic to
* it's file format, such as music tags. * the file or it's file format, such as music tags.
* @param cursor The [Cursor] to read from. * @param cursor The [Cursor] to read from.
* @param raw The [Song.Raw] to populate. * @param raw The [RealSong.Raw] to populate.
* @see populateFileData * @see populateFileData
*/ */
protected open fun populateMetadata(cursor: Cursor, raw: Song.Raw) { protected open fun populateMetadata(cursor: Cursor, raw: RealSong.Raw) {
// Song title // Song title
raw.name = cursor.getString(titleIndex) raw.name = cursor.getString(titleIndex)
// Size (in bytes) // Size (in bytes)
@ -408,7 +408,7 @@ private class Api21MediaStoreExtractor(context: Context, cacheExtractor: CacheEx
return true return true
} }
override fun populateFileData(cursor: Cursor, raw: Song.Raw) { override fun populateFileData(cursor: Cursor, raw: RealSong.Raw) {
super.populateFileData(cursor, raw) super.populateFileData(cursor, raw)
val data = cursor.getString(dataIndex) 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) super.populateMetadata(cursor, raw)
// See unpackTrackNo/unpackDiscNo for an explanation // See unpackTrackNo/unpackDiscNo for an explanation
// of how this column is set up. // of how this column is set up.
@ -495,7 +495,7 @@ private open class BaseApi29MediaStoreExtractor(context: Context, cacheExtractor
return true return true
} }
override fun populateFileData(cursor: Cursor, raw: Song.Raw) { override fun populateFileData(cursor: Cursor, raw: RealSong.Raw) {
super.populateFileData(cursor, raw) super.populateFileData(cursor, raw)
// Find the StorageVolume whose MediaStore name corresponds to this song. // Find the StorageVolume whose MediaStore name corresponds to this song.
// This is combined with the plain relative path column to create the directory. // This is combined with the plain relative path column to create the directory.
@ -530,7 +530,7 @@ private open class Api29MediaStoreExtractor(context: Context, cacheExtractor: Ca
override val projection: Array<String> override val projection: Array<String>
get() = super.projection + arrayOf(MediaStore.Audio.AudioColumns.TRACK) get() = super.projection + arrayOf(MediaStore.Audio.AudioColumns.TRACK)
override fun populateMetadata(cursor: Cursor, raw: Song.Raw) { override fun populateMetadata(cursor: Cursor, raw: RealSong.Raw) {
super.populateMetadata(cursor, raw) super.populateMetadata(cursor, raw)
// This extractor is volume-aware, but does not support the modern track columns. // This extractor is volume-aware, but does not support the modern track columns.
// Use the old column instead. See unpackTrackNo/unpackDiscNo for an explanation // Use the old column instead. See unpackTrackNo/unpackDiscNo for an explanation
@ -573,7 +573,7 @@ private class Api30MediaStoreExtractor(context: Context, cacheExtractor: CacheEx
MediaStore.Audio.AudioColumns.CD_TRACK_NUMBER, MediaStore.Audio.AudioColumns.CD_TRACK_NUMBER,
MediaStore.Audio.AudioColumns.DISC_NUMBER) MediaStore.Audio.AudioColumns.DISC_NUMBER)
override fun populateMetadata(cursor: Cursor, raw: Song.Raw) { override fun populateMetadata(cursor: Cursor, raw: RealSong.Raw) {
super.populateMetadata(cursor, raw) super.populateMetadata(cursor, raw)
// Both CD_TRACK_NUMBER and DISC_NUMBER tend to be formatted as they are in // Both CD_TRACK_NUMBER and DISC_NUMBER tend to be formatted as they are in
// the tag itself, which is to say that it is formatted as NN/TT tracks, where // the tag itself, which is to say that it is formatted as NN/TT tracks, where

View file

@ -22,7 +22,7 @@ import androidx.core.text.isDigitsOnly
import com.google.android.exoplayer2.MediaItem import com.google.android.exoplayer2.MediaItem
import com.google.android.exoplayer2.MetadataRetriever import com.google.android.exoplayer2.MetadataRetriever
import kotlinx.coroutines.flow.flow 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.Date
import org.oxycblt.auxio.music.format.TextTags import org.oxycblt.auxio.music.format.TextTags
import org.oxycblt.auxio.music.parsing.parseId3v2PositionField import org.oxycblt.auxio.music.parsing.parseId3v2PositionField
@ -57,20 +57,20 @@ class MetadataExtractor(
suspend fun init() = mediaStoreExtractor.init().count suspend fun init() = mediaStoreExtractor.init().count
/** /**
* Finalize the Extractor by writing the newly-loaded [Song.Raw]s back into the cache, alongside * Finalize the Extractor by writing the newly-loaded [RealSong.Raw]s back into the cache,
* freeing up memory. * alongside freeing up memory.
* @param rawSongs The songs to write into the cache. * @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 * Returns a flow that parses all [RealSong.Raw] instances queued by the sub-extractors. This
* first delegate to the sub-extractors before parsing the metadata itself. * will first delegate to the sub-extractors before parsing the metadata itself.
* @return A flow of [Song.Raw] instances. * @return A flow of [RealSong.Raw] instances.
*/ */
fun extract() = flow { fun extract() = flow {
while (true) { while (true) {
val raw = Song.Raw() val raw = RealSong.Raw()
when (mediaStoreExtractor.populate(raw)) { when (mediaStoreExtractor.populate(raw)) {
ExtractionResult.NONE -> break ExtractionResult.NONE -> break
ExtractionResult.PARSED -> {} 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 context [Context] required to open the audio file.
* @param raw [Song.Raw] to process. * @param raw [RealSong.Raw] to process.
* @author Alexander Capehart (OxygenCobalt) * @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 // Note that we do not leverage future callbacks. This is because errors in the
// (highly fallible) extraction process will not bubble up to Indexer when a // (highly fallible) extraction process will not bubble up to Indexer when a
// listener is used, instead crashing the app entirely. // listener is used, instead crashing the app entirely.
@ -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. * 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) { if (!future.isDone) {
// Not done yet, nothing to do. // Not done yet, nothing to do.
return null 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 * @param textFrames A mapping between ID3v2 Text Identification Frame IDs and one or more
* values. * 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. * @param comments A mapping between vorbis comment names and one or more vorbis comment values.
*/ */
private fun populateWithVorbis(comments: Map<String, List<String>>) { private fun populateWithVorbis(comments: Map<String, List<String>>) {

View file

@ -34,23 +34,70 @@ import org.oxycblt.auxio.util.logD
* *
* @author Alexander Capehart * @author Alexander Capehart
*/ */
class Library(rawSongs: List<Song.Raw>, settings: MusicSettings) { interface Library {
/** All [Song]s that were detected on the device. */ /** All [Song]s in this [Library]. */
val songs = Sort(Sort.Mode.ByName, true).songs(rawSongs.map { Song(it, settings) }.distinct()) val songs: List<Song>
/** All [Album]s found on the device. */ /** All [Album]s in this [Library]. */
val albums = buildAlbums(songs) val albums: List<Album>
/** All [Artist]s found on the device. */ /** All [Artist]s in this [Library]. */
val artists = buildArtists(songs, albums) val artists: List<Artist>
/** All [Genre]s found on the device. */ /** All [Genre]s in this [Library]. */
val genres = buildGenres(songs) 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. // Use a mapping to make finding information based on it's UID much faster.
private val uidMap = buildMap { private val uidMap = buildMap {
for (music in (songs + albums + artists + genres)) { songs.forEach { this[it.uid] = it.finalize() }
// Finalize all music in the same mapping creation loop for efficiency. albums.forEach { this[it.uid] = it.finalize() }
music._finalize() artists.forEach { this[it.uid] = it.finalize() }
this[music.uid] = music 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 * @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]. * 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
/** override fun sanitize(song: Song) = find<Song>(song.uid)
* 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 <T : MusicParent> sanitize(parent: T) = find<T>(parent.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 findSongForUri(context: Context, uri: Uri) =
* 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) =
context.contentResolverSafe.useQuery( context.contentResolverSafe.useQuery(
uri, arrayOf(OpenableColumns.DISPLAY_NAME, OpenableColumns.SIZE)) { cursor -> uri, arrayOf(OpenableColumns.DISPLAY_NAME, OpenableColumns.SIZE)) { cursor ->
cursor.moveToFirst() 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 * @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. * 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 // 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. // grouped songs to Album values. Album.Raw will handle the actual grouping rules.
val songsByAlbum = songs.groupBy { it._rawAlbum } val songsByAlbum = songs.groupBy { it.rawAlbum }
val albums = songsByAlbum.map { Album(it.key, it.value) } val albums = songsByAlbum.map { RealAlbum(it.key, it.value) }
logD("Successfully built ${albums.size} albums") logD("Successfully built ${albums.size} albums")
return 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 * @return A non-empty list of [Artist]s. These [Artist]s will consist of the combined groupings
* of [Song]s and [Album]s. * 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, // Add every raw artist credited to each Song/Album to the grouping. This way,
// different multi-artist combinations are not treated as different artists. // different multi-artist combinations are not treated as different artists.
val musicByArtist = mutableMapOf<Artist.Raw, MutableList<Music>>() val musicByArtist = mutableMapOf<RealArtist.Raw, MutableList<Music>>()
for (song in songs) { for (song in songs) {
for (rawArtist in song._rawArtists) { for (rawArtist in song.rawArtists) {
musicByArtist.getOrPut(rawArtist) { mutableListOf() }.add(song) musicByArtist.getOrPut(rawArtist) { mutableListOf() }.add(song)
} }
} }
for (album in albums) { for (album in albums) {
for (rawArtist in album._rawArtists) { for (rawArtist in album.rawArtists) {
musicByArtist.getOrPut(rawArtist) { mutableListOf() }.add(album) musicByArtist.getOrPut(rawArtist) { mutableListOf() }.add(album)
} }
} }
// Convert the combined mapping into artist instances. // 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") logD("Successfully built ${artists.size} artists")
return artists return artists
} }
@ -152,18 +183,18 @@ class Library(rawSongs: List<Song.Raw>, settings: MusicSettings) {
* created. * created.
* @return A non-empty list of [Genre]s. * @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, // Add every raw genre credited to each Song to the grouping. This way,
// different multi-genre combinations are not treated as different genres. // different multi-genre combinations are not treated as different genres.
val songsByGenre = mutableMapOf<Genre.Raw, MutableList<Song>>() val songsByGenre = mutableMapOf<RealGenre.Raw, MutableList<RealSong>>()
for (song in songs) { for (song in songs) {
for (rawGenre in song._rawGenres) { for (rawGenre in song.rawGenres) {
songsByGenre.getOrPut(rawGenre) { mutableListOf() }.add(song) songsByGenre.getOrPut(rawGenre) { mutableListOf() }.add(song)
} }
} }
// Convert the mapping into genre instances. // 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") logD("Successfully built ${genres.size} genres")
return 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. * @param songs The list of [Song]s.
* @return A new list of [Song]s sorted by this [Sort]'s configuration. * @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() val mutable = songs.toMutableList()
songsInPlace(mutable) songsInPlace(mutable)
return mutable return mutable
@ -66,7 +66,7 @@ data class Sort(val mode: Mode, val isAscending: Boolean) {
* @param albums The list of [Album]s. * @param albums The list of [Album]s.
* @return A new list of [Album]s sorted by this [Sort]'s configuration. * @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() val mutable = albums.toMutableList()
albumsInPlace(mutable) albumsInPlace(mutable)
return mutable return mutable
@ -77,7 +77,7 @@ data class Sort(val mode: Mode, val isAscending: Boolean) {
* @param artists The list of [Artist]s. * @param artists The list of [Artist]s.
* @return A new list of [Artist]s sorted by this [Sort]'s configuration. * @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() val mutable = artists.toMutableList()
artistsInPlace(mutable) artistsInPlace(mutable)
return mutable return mutable
@ -88,7 +88,7 @@ data class Sort(val mode: Mode, val isAscending: Boolean) {
* @param genres The list of [Genre]s. * @param genres The list of [Genre]s.
* @return A new list of [Genre]s sorted by this [Sort]'s configuration. * @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() val mutable = genres.toMutableList()
genresInPlace(mutable) genresInPlace(mutable)
return 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. * Sort a *mutable* list of [Song]s in-place using this [Sort]'s configuration.
* @param songs The [Song]s to sort. * @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)) 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. * Sort a *mutable* list of [Album]s in-place using this [Sort]'s configuration.
* @param albums The [Album]s to sort. * @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)) 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. * Sort a *mutable* list of [Artist]s in-place using this [Sort]'s configuration.
* @param artists The [Album]s to sort. * @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)) 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. * Sort a *mutable* list of [Genre]s in-place using this [Sort]'s configuration.
* @param genres The [Genre]s to sort. * @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)) 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 // 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. // and reliable compared to using MediaStore to obtain grouping information.
val buildStart = System.currentTimeMillis() 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") logD("Successfully built library in ${System.currentTimeMillis() - buildStart}ms")
return library return library
} }
private suspend fun loadRawSongs(metadataExtractor: MetadataExtractor): List<Song.Raw> { private suspend fun loadRawSongs(metadataExtractor: MetadataExtractor): List<RealSong.Raw> {
logD("Starting indexing process") logD("Starting indexing process")
val start = System.currentTimeMillis() val start = System.currentTimeMillis()
// Start initializing the extractors. Use an indeterminate state, as there is no ETA on // Start initializing the extractors. Use an indeterminate state, as there is no ETA on
@ -352,7 +352,7 @@ private class RealIndexer : Indexer {
yield() yield()
// Note: We use a set here so we can eliminate song duplicates. // 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 -> metadataExtractor.extract().collect { rawSong ->
rawSongs.add(rawSong) rawSongs.add(rawSong)
// Now we can signal a defined progress by showing how many songs we have // 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 package org.oxycblt.auxio.util
import android.os.Looper import java.util.UUID
import kotlin.reflect.KClass import kotlin.reflect.KClass
import org.oxycblt.auxio.BuildConfig 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 * Convert a [String] to a [UUID].
* don't necessarily require suspend, but still want to ensure that they are being called with a * @return A [UUID] converted from the [String] value, or null if the value was not valid.
* co-routine. * @see UUID.fromString
* @throws IllegalStateException If the execution is not on a background thread.
*/ */
fun requireBackgroundThread() { fun String.toUuidOrNull(): UUID? =
check(Looper.myLooper() != Looper.getMainLooper()) { try {
"This operation must be ran on a background thread" UUID.fromString(this)
} catch (e: IllegalArgumentException) {
null
} }
}