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