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