diff --git a/app/src/main/java/org/oxycblt/auxio/music/Music.kt b/app/src/main/java/org/oxycblt/auxio/music/Music.kt index af0770036..fabfdb7b8 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/Music.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/Music.kt @@ -20,7 +20,6 @@ package org.oxycblt.auxio.music import android.content.Context -import android.net.Uri import kotlin.math.max import kotlin.math.min import org.oxycblt.auxio.BuildConfig @@ -64,47 +63,11 @@ sealed class MusicParent : Music() { abstract val songs: List } -/** The data object for a song. */ -data class Song( - override val rawName: String, - override val rawSortName: String?, - /** The path of this song. */ - val path: Path, - /** The URI linking to this song's file. */ - val uri: Uri, - /** The mime type of this song. */ - val mimeType: MimeType, - /** The size of this song (in bytes) */ - val size: Long, - /** The datetime at which this media item was added, represented as a unix timestamp. */ - val dateAdded: Long, - /** The total duration of this song, in millis. */ - val durationMs: Long, - /** The track number of this song, null if there isn't any. */ - val track: Int?, - /** The disc number of this song, null if there isn't any. */ - val disc: Int?, - /** Internal field. Do not use. */ - val _date: Date?, - /** Internal field. Do not use. */ - val _albumName: String, - /** Internal field. Do not use. */ - val _albumSortName: String?, - /** Internal field. Do not use. */ - val _albumReleaseType: ReleaseType?, - /** Internal field. Do not use. */ - val _albumCoverUri: Uri, - /** Internal field. Do not use. */ - val _artistName: String?, - /** Internal field. Do not use. */ - val _artistSortName: String?, - /** Internal field. Do not use. */ - val _albumArtistName: String?, - /** Internal field. Do not use. */ - val _albumArtistSortName: String?, - /** Internal field. Do not use. */ - val _genreName: String? -) : Music() { +/** + * A song. + * @author OxygenCobalt + */ +data class Song(private val raw: Raw) : Music() { override val id: Long get() { var result = rawName.toMusicId() @@ -116,8 +79,45 @@ data class Song( return result } + override val rawName = requireNotNull(raw.name) { "Invalid raw: No title" } + + override val rawSortName = raw.sortName + override fun resolveName(context: Context) = rawName + /** The URI pointing towards this audio file. */ + val uri = requireNotNull(raw.mediaStoreId) { "Invalid raw: No id" }.audioUri + + /** + * The path component of the audio file for this music. Only intended for display. Use [uri] to + * open the audio file. + */ + val path = + Path( + name = requireNotNull(raw.displayName) { "Invalid raw: No display name" }, + parent = requireNotNull(raw.directory) { "Invalid raw: No parent directory" }) + + /** The mime type of the audio file. Only intended for display. */ + val mimeType = + MimeType( + fromExtension = requireNotNull(raw.extensionMimeType) { "Invalid raw: No mime type" }, + fromFormat = raw.formatMimeType) + + /** The size of this audio file. */ + val size = requireNotNull(raw.size) { "Invalid raw: No size" } + + /** The duration of this audio file, in millis. */ + val durationMs = requireNotNull(raw.durationMs) { "Invalid raw: No duration" } + + /** The date this audio file was added, as a unix epoch timestamp. */ + val dateAdded = requireNotNull(raw.dateAdded) { "Invalid raw: No date added" } + + /** The track number of this song in it's album.. */ + val track = raw.track + + /** The disc number of this song in it's album. */ + val disc = raw.disc + private var _album: Album? = null /** The album of this song. */ val album: Album @@ -133,78 +133,99 @@ data class Song( * back to the album artist tag (i.e parent artist name). Null if name is unknown. */ val individualArtistRawName: String? - get() = _artistName ?: album.artist.rawName + get() = raw.artistName ?: album.artist.rawName /** * Resolve the artist name for this song in particular. First uses the artist tag, and then * falls back to the album artist tag (i.e parent artist name) */ fun resolveIndividualArtistName(context: Context) = - _artistName ?: album.artist.resolveName(context) + raw.artistName ?: album.artist.resolveName(context) - /** Internal field. Do not use. */ - val _albumGroupingId: Long - get() { - var result = _artistGroupingName.toMusicId() - result = 31 * result + _albumName.toMusicId() - return result - } + // --- INTERNAL FIELDS --- - /** Internal field. Do not use. */ - val _genreGroupingId: Long - get() = _genreName.toMusicId() + val _distinct = + rawName to + raw.albumName to + raw.artistName to + raw.albumArtistName to + raw.genreName to + track to + disc to + durationMs - /** Internal field. Do not use. */ - val _artistGroupingName: String? - get() = _albumArtistName ?: _artistName + val _rawAlbum: Album.Raw - /** Internal field. Do not use. */ - val _artistGroupingSortName: String? - get() = - when { - _albumArtistName != null -> _albumArtistSortName - _artistName != null -> _artistSortName - else -> null - } + val _rawGenre = Genre.Raw(raw.genreName) - /** Internal field. Do not use. */ val _isMissingAlbum: Boolean get() = _album == null - /** Internal field. Do not use. */ + val _isMissingArtist: Boolean get() = _album?._isMissingArtist ?: true - /** Internal field. Do not use. */ + val _isMissingGenre: Boolean get() = _genre == null - /** Internal method. Do not use. */ fun _link(album: Album) { _album = album } - /** Internal method. Do not use. */ fun _link(genre: Genre) { _genre = genre } + + init { + val artistName: String? + val artistSortName: String? + + if (raw.albumArtistName != null) { + artistName = raw.albumArtistName + artistSortName = raw.albumArtistSortName + } else { + artistName = raw.artistName + artistSortName = raw.artistSortName + } + + _rawAlbum = + Album.Raw( + mediaStoreId = raw.albumMediaStoreId, + name = raw.albumName, + sortName = raw.albumSortName, + date = raw.date, + releaseType = raw.albumReleaseType, + artistName, + artistSortName) + } + + data class Raw( + var mediaStoreId: Long? = null, + var name: String? = null, + var sortName: String? = null, + var displayName: String? = null, + var directory: Directory? = null, + var extensionMimeType: String? = null, + var formatMimeType: String? = null, + var size: Long? = null, + var dateAdded: Long? = null, + var durationMs: Long? = null, + var track: Int? = null, + var disc: Int? = null, + var date: Date? = null, + var albumMediaStoreId: Long? = null, + var albumName: String? = null, + var albumSortName: String? = null, + var albumReleaseType: ReleaseType? = null, + var artistName: String? = null, + var artistSortName: String? = null, + var albumArtistName: String? = null, + var albumArtistSortName: String? = null, + var genreName: String? = null + ) } /** The data object for an album. */ -data class Album( - override val rawName: String, - override val rawSortName: String?, - /** The date this album was released. */ - val date: Date?, - /** The type of release this album has. */ - val releaseType: ReleaseType, - /** The URI for the cover image corresponding to this album. */ - val coverUri: Uri, - /** The songs of this album. */ - override val songs: List, - /** Internal field. Do not use. */ - val _artistGroupingName: String?, - /** Internal field. Do not use. */ - val _artistGroupingSortName: String? -) : MusicParent() { +data class Album(private val raw: Raw, override val songs: List) : MusicParent() { init { for (song in songs) { song._link(this) @@ -219,8 +240,24 @@ data class Album( return result } + override val rawName = requireNotNull(raw.name) { "Invalid raw: No name" } + + override val rawSortName = raw.sortName + override fun resolveName(context: Context) = rawName + /** + * The album cover URI for this album. Usually low quality, so using Coil is recommended + * instead. + */ + val coverUri = requireNotNull(raw.mediaStoreId) { "Invalid raw: No id" }.albumCoverUri + + /** The latest date this album was released. */ + val date = raw.date + + /** The release type of this album, such as "EP". Defaults to "Album". */ + val releaseType = raw.releaseType ?: ReleaseType.Album(null) + private var _artist: Artist? = null /** The parent artist of this album. */ val artist: Artist @@ -229,21 +266,38 @@ data class Album( /** The earliest date a song in this album was added. */ val dateAdded = songs.minOf { it.dateAdded } - val durationMs: Long - get() = songs.sumOf { it.durationMs } + /** The total duration of songs in this album, in millis. */ + val durationMs = songs.sumOf { it.durationMs } - /** Internal field. Do not use. */ - val _artistGroupingId: Long - get() = _artistGroupingName.toMusicId() + // --- INTERNAL FIELDS --- + + val _rawArtist: Artist.Raw + get() = Artist.Raw(name = raw.artistName, sortName = raw.artistSortName) - /** Internal field. Do not use. */ val _isMissingArtist: Boolean get() = _artist == null - /** Internal method. Do not use. */ fun _link(artist: Artist) { _artist = artist } + + data class Raw( + val mediaStoreId: Long?, + val name: String?, + val sortName: String?, + val date: Date?, + val releaseType: ReleaseType?, + val artistName: String?, + val artistSortName: String?, + ) { + val groupingId: Long + + init { + var groupingIdResult = artistName.toMusicId() + groupingIdResult = 31 * groupingIdResult + name.toMusicId() + groupingId = groupingIdResult + } + } } /** @@ -251,8 +305,7 @@ data class Album( * artist or artist field, not the individual performers of an artist. */ data class Artist( - override val rawName: String?, - override val rawSortName: String?, + private val raw: Raw, /** The albums of this artist. */ val albums: List ) : MusicParent() { @@ -265,23 +318,33 @@ data class Artist( override val id: Long get() = rawName.toMusicId() + override val rawName = raw.name + + override val rawSortName = raw.sortName + override fun resolveName(context: Context) = rawName ?: context.getString(R.string.def_artist) - /** The songs of this artist. */ override val songs = albums.flatMap { it.songs } - val durationMs: Long - get() = songs.sumOf { it.durationMs } + /** The total duration of songs in this artist, in millis. */ + val durationMs = songs.sumOf { it.durationMs } + + data class Raw(val name: String?, val sortName: String?) { + val groupingId = name.toMusicId() + } } /** The data object for a genre. */ -data class Genre(override val rawName: String?, override val songs: List) : MusicParent() { +data class Genre(private val raw: Raw, override val songs: List) : MusicParent() { init { for (song in songs) { song._link(this) } } + override val rawName: String? + get() = raw.name + // Sort tags don't make sense on genres override val rawSortName: String? get() = rawName @@ -291,8 +354,12 @@ data class Genre(override val rawName: String?, override val songs: List) override fun resolveName(context: Context) = rawName ?: context.getString(R.string.def_genre) - val durationMs: Long - get() = songs.sumOf { it.durationMs } + /** The total duration of the songs in this genre, in millis. */ + val durationMs = songs.sumOf { it.durationMs } + + data class Raw(val name: String?) { + val groupingId: Long = name.toMusicId() + } } private fun String?.toMusicId(): Long { diff --git a/app/src/main/java/org/oxycblt/auxio/music/system/ExoPlayerBackend.kt b/app/src/main/java/org/oxycblt/auxio/music/system/ExoPlayerBackend.kt index 3fe77837c..6a40a132b 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/system/ExoPlayerBackend.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/system/ExoPlayerBackend.kt @@ -67,13 +67,11 @@ class ExoPlayerBackend(private val inner: MediaStoreBackend) : Indexer.Backend { val songs = mutableListOf() val total = cursor.count - // LEFTOFF: Make logic more consistent? - while (cursor.moveToNext()) { // Note: This call to buildAudio does not populate the genre field. This is // because indexing genres is quite slow with MediaStore, and so keeping the // field blank on unsupported ExoPlayer formats ends up being preferable. - val audio = inner.buildAudio(context, cursor) + val raw = inner.buildRawSong(context, cursor) // Spin until there is an open slot we can insert a task in. Note that we do // not add callbacks to our new tasks, as Future callbacks run on a different @@ -88,11 +86,11 @@ class ExoPlayerBackend(private val inner: MediaStoreBackend) : Indexer.Backend { if (song != null) { songs.add(song) emitIndexing(Indexer.Indexing.Songs(songs.size, total)) - runningTasks[i] = Task(context, audio) + runningTasks[i] = Task(context, raw) break@spin } } else { - runningTasks[i] = Task(context, audio) + runningTasks[i] = Task(context, raw) break@spin } } @@ -128,11 +126,11 @@ class ExoPlayerBackend(private val inner: MediaStoreBackend) : Indexer.Backend { * Wraps an ExoPlayer metadata retrieval task in a safe abstraction. Access is done with [get]. * @author OxygenCobalt */ -class Task(context: Context, private val audio: MediaStoreBackend.Audio) { +class Task(context: Context, private val raw: Song.Raw) { private val future = MetadataRetriever.retrieveMetadata( context, - MediaItem.fromUri(requireNotNull(audio.id) { "Malformed audio: No id" }.audioUri)) + MediaItem.fromUri(requireNotNull(raw.mediaStoreId) { "Invalid raw: No id" }.audioUri)) /** * Get the song that this task is trying to complete. If the task is still busy, this will @@ -147,27 +145,27 @@ class Task(context: Context, private val audio: MediaStoreBackend.Audio) { try { future.get()[0].getFormat(0) } catch (e: Exception) { - logW("Unable to extract metadata for ${audio.title}") + logW("Unable to extract metadata for ${raw.name}") logW(e.stackTraceToString()) null } if (format == null) { - logD("Nothing could be extracted for ${audio.title}") - return audio.toSong() + logD("Nothing could be extracted for ${raw.name}") + return Song(raw) } // Populate the format mime type if we have one. - format.sampleMimeType?.let { audio.formatMimeType = it } + format.sampleMimeType?.let { raw.formatMimeType = it } val metadata = format.metadata if (metadata != null) { completeAudio(metadata) } else { - logD("No metadata could be extracted for ${audio.title}") + logD("No metadata could be extracted for ${raw.name}") } - return audio.toSong() + return Song(raw) } private fun completeAudio(metadata: Metadata) { @@ -215,14 +213,14 @@ class Task(context: Context, private val audio: MediaStoreBackend.Audio) { private fun populateId3v2(tags: Map) { // (Sort) Title - tags["TIT2"]?.let { audio.title = it } - tags["TSOT"]?.let { audio.sortTitle = it } + tags["TIT2"]?.let { raw.name = it } + tags["TSOT"]?.let { raw.sortName = it } // Track, as NN/TT - tags["TRCK"]?.parsePositionNum()?.let { audio.track = it } + tags["TRCK"]?.parsePositionNum()?.let { raw.track = it } // Disc, as NN/TT - tags["TPOS"]?.parsePositionNum()?.let { audio.disc = it } + tags["TPOS"]?.parsePositionNum()?.let { raw.disc = it } // Dates are somewhat complicated, as not only did their semantics change from a flat year // value in ID3v2.3 to a full ISO-8601 date in ID3v2.4, but there are also a variety of @@ -236,26 +234,26 @@ class Task(context: Context, private val audio: MediaStoreBackend.Audio) { (tags["TDOR"]?.parseTimestamp() ?: tags["TDRC"]?.parseTimestamp() ?: tags["TDRL"]?.parseTimestamp() ?: parseId3v23Date(tags)) - ?.let { audio.date = it } + ?.let { raw.date = it } // (Sort) Album - tags["TALB"]?.let { audio.album = it } - tags["TSOA"]?.let { audio.sortAlbum = it } + tags["TALB"]?.let { raw.albumName = it } + tags["TSOA"]?.let { raw.albumSortName = it } // (Sort) Artist - tags["TPE1"]?.let { audio.artist = it } - tags["TSOP"]?.let { audio.sortArtist = it } + tags["TPE1"]?.let { raw.artistName = it } + tags["TSOP"]?.let { raw.artistSortName = it } // (Sort) Album artist - tags["TPE2"]?.let { audio.albumArtist = it } - tags["TSO2"]?.let { audio.sortAlbumArtist = it } + tags["TPE2"]?.let { raw.albumArtistName = it } + tags["TSO2"]?.let { raw.albumArtistSortName = it } // Genre, with the weird ID3 rules. - tags["TCON"]?.let { audio.genre = it.parseId3GenreName() } + tags["TCON"]?.let { raw.genreName = it.parseId3GenreName() } // Release type (GRP1 is sometimes used for this, so fall back to it) (tags["TXXX:MusicBrainz Album Type"] ?: tags["GRP1"])?.parseReleaseType()?.let { - audio.releaseType = it + raw.albumReleaseType = it } } @@ -282,14 +280,14 @@ class Task(context: Context, private val audio: MediaStoreBackend.Audio) { private fun populateVorbis(tags: Map>) { // (Sort) Title - tags["TITLE"]?.let { audio.title = it[0] } - tags["TITLESORT"]?.let { audio.sortTitle = it[0] } + tags["TITLE"]?.let { raw.name = it[0] } + tags["TITLESORT"]?.let { raw.sortName = it[0] } // Track - tags["TRACKNUMBER"]?.run { get(0).parsePositionNum() }?.let { audio.track = it } + tags["TRACKNUMBER"]?.run { get(0).parsePositionNum() }?.let { raw.track = it } // Disc - tags["DISCNUMBER"]?.run { get(0).parsePositionNum() }?.let { audio.disc = it } + tags["DISCNUMBER"]?.run { get(0).parsePositionNum() }?.let { raw.disc = it } // Vorbis dates are less complicated, but there are still several types // Our hierarchy for dates is as such: @@ -300,25 +298,25 @@ class Task(context: Context, private val audio: MediaStoreBackend.Audio) { (tags["ORIGINALDATE"]?.run { get(0).parseTimestamp() } ?: tags["DATE"]?.run { get(0).parseTimestamp() } ?: tags["YEAR"]?.run { get(0).parseYear() }) - ?.let { audio.date = it } + ?.let { raw.date = it } // (Sort) Album - tags["ALBUM"]?.let { audio.album = it.joinToString() } - tags["ALBUMSORT"]?.let { audio.sortAlbum = it.joinToString() } + tags["ALBUM"]?.let { raw.albumName = it.joinToString() } + tags["ALBUMSORT"]?.let { raw.albumSortName = it.joinToString() } // (Sort) Artist - tags["ARTIST"]?.let { audio.artist = it.joinToString() } - tags["ARTISTSORT"]?.let { audio.sortArtist = it.joinToString() } + tags["ARTIST"]?.let { raw.artistName = it.joinToString() } + tags["ARTISTSORT"]?.let { raw.artistSortName = it.joinToString() } // (Sort) Album artist - tags["ALBUMARTIST"]?.let { audio.albumArtist = it.joinToString() } - tags["ALBUMARTISTSORT"]?.let { audio.sortAlbumArtist = it.joinToString() } + tags["ALBUMARTIST"]?.let { raw.albumArtistName = it.joinToString() } + tags["ALBUMARTISTSORT"]?.let { raw.albumArtistSortName = it.joinToString() } // Genre, no ID3 rules here - tags["GENRE"]?.let { audio.genre = it.joinToString() } + tags["GENRE"]?.let { raw.genreName = it.joinToString() } // Release type - tags["RELEASETYPE"]?.parseReleaseType()?.let { audio.releaseType = it } + tags["RELEASETYPE"]?.parseReleaseType()?.let { raw.albumReleaseType = it } } /** diff --git a/app/src/main/java/org/oxycblt/auxio/music/system/Indexer.kt b/app/src/main/java/org/oxycblt/auxio/music/system/Indexer.kt index 81539f0f2..95842213f 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/system/Indexer.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/system/Indexer.kt @@ -262,19 +262,7 @@ class Indexer { } // Deduplicate songs to prevent (most) deformed music clones - songs = - songs - .distinctBy { - it.rawName to - it._albumName to - it._artistName to - it._albumArtistName to - it._genreName to - it.track to - it.disc to - it.durationMs - } - .toMutableList() + songs = songs.distinctBy { it._distinct }.toMutableList() // Ensure that sorting order is consistent so that grouping is also consistent. Sort(Sort.Mode.ByName, true).songsInPlace(songs) @@ -299,7 +287,7 @@ class Indexer { */ private fun buildAlbums(songs: List): List { val albums = mutableListOf() - val songsByAlbum = songs.groupBy { it._albumGroupingId } + val songsByAlbum = songs.groupBy { it._rawAlbum.groupingId } for (entry in songsByAlbum) { val albumSongs = entry.value @@ -308,18 +296,10 @@ class Indexer { // This allows us to replicate the LAST_YEAR field, which is useful as it means that // weird years like "0" wont show up if there are alternatives. val templateSong = - albumSongs.maxWith(compareBy(Sort.Mode.NullableComparator.DATE) { it._date }) + albumSongs.maxWith( + compareBy(Sort.Mode.NullableComparator.DATE) { it._rawAlbum.date }) - albums.add( - Album( - rawName = templateSong._albumName, - rawSortName = templateSong._albumSortName, - date = templateSong._date, - releaseType = templateSong._albumReleaseType ?: ReleaseType.Album(null), - coverUri = templateSong._albumCoverUri, - songs = entry.value, - _artistGroupingName = templateSong._artistGroupingName, - _artistGroupingSortName = templateSong._artistGroupingSortName)) + albums.add(Album(templateSong._rawAlbum, albumSongs)) } logD("Successfully built ${albums.size} albums") @@ -333,16 +313,12 @@ class Indexer { */ private fun buildArtists(albums: List): List { val artists = mutableListOf() - val albumsByArtist = albums.groupBy { it._artistGroupingId } + val albumsByArtist = albums.groupBy { it._rawArtist.groupingId } for (entry in albumsByArtist) { // The first album will suffice for template metadata. val templateAlbum = entry.value[0] - artists.add( - Artist( - rawName = templateAlbum._artistGroupingName, - rawSortName = templateAlbum._artistGroupingSortName, - albums = entry.value)) + artists.add(Artist(templateAlbum._rawArtist, albums = entry.value)) } logD("Successfully built ${artists.size} artists") @@ -356,12 +332,12 @@ class Indexer { */ private fun buildGenres(songs: List): List { val genres = mutableListOf() - val songsByGenre = songs.groupBy { it._genreGroupingId } + val songsByGenre = songs.groupBy { it._rawGenre.groupingId } for (entry in songsByGenre) { // The first song fill suffice for template metadata. val templateSong = entry.value[0] - genres.add(Genre(rawName = templateSong._genreName, songs = entry.value)) + genres.add(Genre(templateSong._rawGenre, songs = entry.value)) } logD("Successfully built ${genres.size} genres") diff --git a/app/src/main/java/org/oxycblt/auxio/music/system/MediaStoreBackend.kt b/app/src/main/java/org/oxycblt/auxio/music/system/MediaStoreBackend.kt index 6b3fdd56b..89ec99574 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/system/MediaStoreBackend.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/system/MediaStoreBackend.kt @@ -163,9 +163,9 @@ abstract class MediaStoreBackend : Indexer.Backend { cursor: Cursor, emitIndexing: (Indexer.Indexing) -> Unit ): List { - val audios = mutableListOf