From 19a0728e5bd1460d316a8228fe9ba1b08a3c8151 Mon Sep 17 00:00:00 2001 From: OxygenCobalt Date: Wed, 20 Jul 2022 10:40:41 -0600 Subject: [PATCH] music: rework release types [#158] Rework album types into release types, with additional support for live albums, remixes, and mixtapes. This is not a complete implementation, nor is it meant to be. I don't want to add technical complexity handling Remix Compilations or DJ-Mixes unless there is demand. --- app/build.gradle | 4 +- .../oxycblt/auxio/detail/DetailViewModel.kt | 64 +++++-- .../detail/recycler/AlbumDetailAdapter.kt | 6 +- .../java/org/oxycblt/auxio/music/Music.kt | 164 ++++++++++++------ .../java/org/oxycblt/auxio/music/MusicUtil.kt | 9 +- .../auxio/music/system/ExoPlayerBackend.kt | 8 +- .../org/oxycblt/auxio/music/system/Indexer.kt | 15 +- .../auxio/music/system/MediaStoreBackend.kt | 22 +-- .../oxycblt/auxio/ui/recycler/ViewHolders.kt | 2 +- app/src/main/res/values/strings.xml | 24 ++- info/FAQ.md | 18 +- 11 files changed, 220 insertions(+), 116 deletions(-) diff --git a/app/build.gradle b/app/build.gradle index a3055579a..47ec4e6a9 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -12,6 +12,9 @@ android { versionCode 18 minSdkVersion 21 + + // API 33 is still busted, waiting until the XML element issue is fixed + // noinspection OldTargetApi targetSdkVersion 32 buildFeatures { @@ -20,7 +23,6 @@ android { } compileSdkVersion 32 - buildToolsVersion '33.0.0' // ExoPlayer, AndroidX, and Material Components all need Java 8 to compile. diff --git a/app/src/main/java/org/oxycblt/auxio/detail/DetailViewModel.kt b/app/src/main/java/org/oxycblt/auxio/detail/DetailViewModel.kt index fcb159c9c..84159403a 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/DetailViewModel.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/DetailViewModel.kt @@ -29,12 +29,7 @@ import kotlinx.coroutines.launch import org.oxycblt.auxio.R import org.oxycblt.auxio.detail.recycler.DiscHeader import org.oxycblt.auxio.detail.recycler.SortHeader -import org.oxycblt.auxio.music.Album -import org.oxycblt.auxio.music.Artist -import org.oxycblt.auxio.music.Genre -import org.oxycblt.auxio.music.MimeType -import org.oxycblt.auxio.music.MusicStore -import org.oxycblt.auxio.music.Song +import org.oxycblt.auxio.music.* import org.oxycblt.auxio.settings.Settings import org.oxycblt.auxio.ui.Sort import org.oxycblt.auxio.ui.recycler.Header @@ -223,7 +218,7 @@ class DetailViewModel(application: Application) : // To create a good user experience regarding disc numbers, we intersperse // items that show the disc number throughout the album's songs. In the case - // that the album does not have distinct disc numbers, we omit such a header + // that the album does not have distinct disc numbers, we omit such a header. val songs = albumSort.songs(album.songs) val byDisc = songs.groupBy { it.disc ?: 1 } if (byDisc.size > 1) { @@ -245,14 +240,33 @@ class DetailViewModel(application: Application) : val data = mutableListOf(artist) val albums = Sort(Sort.Mode.ByYear, false).albums(artist.albums) - // Organize albums by their release type. We do not dor - val byType = albums.groupBy { it.type ?: Album.Type.ALBUM } - byType.keys.sorted().forEachIndexed { index, type -> - data.add(Header(-2L - index, type.pluralStringRes)) - data.addAll(unlikelyToBeNull(byType[type])) + val byGroup = + albums.groupBy { + if (it.releaseType == null) { + return@groupBy ArtistAlbumGrouping.ALBUMS + } + + when (it.releaseType.refinement) { + null -> + when (it.releaseType) { + is ReleaseType.Album -> ArtistAlbumGrouping.ALBUMS + is ReleaseType.EP -> ArtistAlbumGrouping.EPS + is ReleaseType.Single -> ArtistAlbumGrouping.SINGLES + is ReleaseType.Compilation -> ArtistAlbumGrouping.COMPILATIONS + is ReleaseType.Soundtrack -> ArtistAlbumGrouping.SOUNDTRACKS + is ReleaseType.Mixtape -> ArtistAlbumGrouping.MIXTAPES + } + ReleaseType.Refinement.LIVE -> ArtistAlbumGrouping.LIVE + ReleaseType.Refinement.REMIX -> ArtistAlbumGrouping.REMIXES + } + } + + for (entry in byGroup.entries.sortedBy { it.key }.withIndex()) { + data.add(Header(-2L - entry.index, entry.value.key.stringRes)) + data.addAll(entry.value.value) } - data.add(SortHeader(-3, R.string.lbl_songs)) + data.add(SortHeader(-2L - byGroup.entries.size, R.string.lbl_songs)) data.addAll(artistSort.songs(artist.songs)) _artistData.value = data.toList() } @@ -312,4 +326,28 @@ class DetailViewModel(application: Application) : override fun onCleared() { musicStore.removeCallback(this) } + + private enum class ArtistAlbumGrouping : Comparable { + ALBUMS, + EPS, + SINGLES, + COMPILATIONS, + SOUNDTRACKS, + MIXTAPES, + REMIXES, + LIVE; + + val stringRes: Int + get() = + when (this) { + ALBUMS -> R.string.lbl_albums + EPS -> R.string.lbl_eps + SINGLES -> R.string.lbl_singles + COMPILATIONS -> R.string.lbl_compilations + SOUNDTRACKS -> R.string.lbl_soundtracks + MIXTAPES -> R.string.lbl_mixtapes + REMIXES -> R.string.lbl_remix_group + LIVE -> R.string.lbl_live_group + } + } } diff --git a/app/src/main/java/org/oxycblt/auxio/detail/recycler/AlbumDetailAdapter.kt b/app/src/main/java/org/oxycblt/auxio/detail/recycler/AlbumDetailAdapter.kt index b04a15a8b..4fb21312f 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/recycler/AlbumDetailAdapter.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/recycler/AlbumDetailAdapter.kt @@ -132,10 +132,10 @@ private class AlbumDetailViewHolder private constructor(private val binding: Ite val duration = item.durationSecs.formatDuration(false) text = - if (item.type != null) { + if (item.releaseType != null) { context.getString( R.string.fmt_four, - context.getString(item.type.stringRes), + context.getString(item.releaseType.stringRes), date, songCount, duration) @@ -166,7 +166,7 @@ private class AlbumDetailViewHolder private constructor(private val binding: Ite oldItem.date == newItem.date && oldItem.songs.size == newItem.songs.size && oldItem.durationSecs == newItem.durationSecs && - oldItem.type == newItem.type + oldItem.releaseType == newItem.releaseType } } } 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 51b3ef510..fee1bf31c 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/Music.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/Music.kt @@ -96,7 +96,7 @@ data class Song( /** Internal field. Do not use. */ val _albumSortName: String?, /** Internal field. Do not use. */ - val _albumType: Album.Type, + val _albumReleaseType: ReleaseType?, /** Internal field. Do not use. */ val _albumCoverUri: Uri, /** Internal field. Do not use. */ @@ -210,7 +210,7 @@ data class Album( * The type of release this album represents. Null if release types were not applicable to this * library. */ - val type: Type?, + val releaseType: ReleaseType?, /** The URI for the cover image corresponding to this album. */ val coverUri: Uri, /** The songs of this album. */ @@ -254,60 +254,6 @@ data class Album( fun _link(artist: Artist) { _artist = artist } - - enum class Type { - ALBUM, - EP, - SINGLE, - COMPILATION, - SOUNDTRACK; - - // I only implemented the release types that I use. If there is sufficient demand, - // I'll extend them to these release types. - // REMIX, LIVE, MIXTAPE - - val stringRes: Int - get() = - when (this) { - ALBUM -> R.string.lbl_album - EP -> R.string.lbl_ep - SINGLE -> R.string.lbl_single - COMPILATION -> R.string.lbl_compilation - SOUNDTRACK -> R.string.lbl_soundtrack - } - - val pluralStringRes: Int - get() = - when (this) { - ALBUM -> R.string.lbl_albums - EP -> R.string.lbl_eps - SINGLE -> R.string.lbl_singles - COMPILATION -> R.string.lbl_compilations - SOUNDTRACK -> R.string.lbl_soundtracks - } - - companion object { - fun parse(type: String): Type { - // Release types (at least to MusicBrainz) are formatted as + - // where primary is something like "album", "ep", or "single", and secondary is - // "compilation", "soundtrack", etc. Use the secondary type as the album type before - // falling back to the primary type. - val primarySecondary = type.split('+').map { it.trim() } - return primarySecondary.getOrNull(1)?.parseReleaseType() - ?: primarySecondary[0].parseReleaseType() ?: ALBUM - } - - private fun String.parseReleaseType() = - when { - equals("album", ignoreCase = true) -> ALBUM - equals("ep", ignoreCase = true) -> EP - equals("single", ignoreCase = true) -> SINGLE - equals("compilation", ignoreCase = true) -> COMPILATION - equals("soundtrack", ignoreCase = true) -> SOUNDTRACK - else -> null - } - } - } } /** @@ -482,3 +428,109 @@ class Date private constructor(private val tokens: List) : Comparable } } } + +sealed class ReleaseType { + abstract val refinement: Refinement? + abstract val stringRes: Int + + data class Album(override val refinement: Refinement?) : ReleaseType() { + override val stringRes: Int + get() = + when (refinement) { + null -> R.string.lbl_album + Refinement.LIVE -> R.string.lbl_album_live + Refinement.REMIX -> R.string.lbl_album_remix + } + } + + data class EP(override val refinement: Refinement?) : ReleaseType() { + override val stringRes: Int + get() = + when (refinement) { + null -> R.string.lbl_ep + Refinement.LIVE -> R.string.lbl_ep_live + Refinement.REMIX -> R.string.lbl_ep_remix + } + } + + data class Single(override val refinement: Refinement?) : ReleaseType() { + override val stringRes: Int + get() = + when (refinement) { + null -> R.string.lbl_single + Refinement.LIVE -> R.string.lbl_single_live + Refinement.REMIX -> R.string.lbl_single_remix + } + } + + object Compilation : ReleaseType() { + override val refinement: Refinement? + get() = null + + override val stringRes: Int + get() = R.string.lbl_compilation + } + + object Soundtrack : ReleaseType() { + override val refinement: Refinement? + get() = null + + override val stringRes: Int + get() = R.string.lbl_soundtrack + } + + object Mixtape : ReleaseType() { + override val refinement: Refinement? + get() = null + + override val stringRes: Int + get() = R.string.lbl_mixtape + } + + enum class Refinement { + LIVE, + REMIX + } + + companion object { + fun parse(type: String): ReleaseType { + val types = type.split('+') + val primary = types[0].trim() + + // Primary types should be the first one in sequence. The spec makes no mention of + // whether primary types are a pre-requisite for secondary types, so we assume that + // it isn't. There are technically two other types, but those are unrelated to music + // and thus we don't support them. + return when { + // Album (+ Other and Broadcast, which don't have meaning in Auxio) correspond to + // Album. + primary.equals("album", true) || + primary.equals("other", true) || + primary.equals("broadcast", true) -> types.parseSecondaryTypes(1) { Album(it) } + primary.equals("ep", true) -> types.parseSecondaryTypes(1) { EP(it) } + primary.equals("single", true) -> types.parseSecondaryTypes(1) { Single(it) } + else -> types.parseSecondaryTypes(0) { Album(it) } + } + } + + private inline fun List.parseSecondaryTypes( + secondaryIdx: Int, + target: (Refinement?) -> ReleaseType + ): ReleaseType { + val secondary = (getOrNull(secondaryIdx) ?: return target(null)).trim() + + return when { + // Compilation is the only weird secondary release type, as it could + // theoretically have additional modifiers including soundtrack, remix, + // live, dj-mix, etc. However, since there is no real demand for me to + // respond to those, I don't implement them simply for internal simplicity. + secondary.equals("compilation", true) -> Compilation + secondary.equals("soundtrack", true) -> Soundtrack + secondary.equals("mixtape/street", true) -> Mixtape + secondary.equals("live", true) -> target(Refinement.REMIX) + secondary.equals("remix", true) -> target(Refinement.LIVE) + else -> target(null) + } + } + } +} diff --git a/app/src/main/java/org/oxycblt/auxio/music/MusicUtil.kt b/app/src/main/java/org/oxycblt/auxio/music/MusicUtil.kt index 834070942..e90c3b5f3 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/MusicUtil.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/MusicUtil.kt @@ -100,8 +100,8 @@ fun String.parseSortName() = else -> this } -/** Shortcut to parse an [Album.Type] from a string */ -fun String.parseAlbumType() = Album.Type.parse(this) +/** Shortcut to parse an [ReleaseType] from a string */ +fun String.parseReleaseType() = ReleaseType.parse(this) /** * Decodes the genre name from an ID3(v2) constant. See [GENRE_TABLE] for the genre constant map @@ -361,4 +361,7 @@ private val GENRE_TABLE = "G-Funk", "Dubstep", "Garage Rock", - "Psybient") + "Psybient", + + // Auxio's extensions (Future garage is also based and deserves a slot) + "Future Garage") 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 3993896ac..6cfee8e7e 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 @@ -28,9 +28,9 @@ import com.google.android.exoplayer2.metadata.vorbis.VorbisComment import org.oxycblt.auxio.music.Date import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.music.audioUri -import org.oxycblt.auxio.music.parseAlbumType import org.oxycblt.auxio.music.parseId3GenreName import org.oxycblt.auxio.music.parsePositionNum +import org.oxycblt.auxio.music.parseReleaseType import org.oxycblt.auxio.music.parseTimestamp import org.oxycblt.auxio.music.parseYear import org.oxycblt.auxio.util.logD @@ -248,8 +248,8 @@ class Task(context: Context, private val audio: MediaStoreBackend.Audio) { tags["TCON"]?.let { audio.genre = it.parseId3GenreName() } // Release type (GRP1 is sometimes used for this, so fall back to it) - (tags["TXXX:MusicBrainz Album Type"] ?: tags["GRP1"])?.parseAlbumType()?.let { - audio.albumType = it + (tags["TXXX:MusicBrainz Album Type"] ?: tags["GRP1"])?.parseReleaseType()?.let { + audio.releaseType = it } } @@ -311,7 +311,7 @@ class Task(context: Context, private val audio: MediaStoreBackend.Audio) { tags["GENRE"]?.let { audio.genre = it } // Release type - tags["RELEASETYPE"]?.parseAlbumType()?.let { audio.albumType = it } + tags["RELEASETYPE"]?.parseReleaseType()?.let { audio.releaseType = 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 ff4b0faff..666a0d951 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 @@ -26,11 +26,7 @@ import androidx.core.content.ContextCompat import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import org.oxycblt.auxio.BuildConfig -import org.oxycblt.auxio.music.Album -import org.oxycblt.auxio.music.Artist -import org.oxycblt.auxio.music.Genre -import org.oxycblt.auxio.music.MusicStore -import org.oxycblt.auxio.music.Song +import org.oxycblt.auxio.music.* import org.oxycblt.auxio.settings.Settings import org.oxycblt.auxio.ui.Sort import org.oxycblt.auxio.util.TaskGuard @@ -301,8 +297,8 @@ class Indexer { val songsByAlbum = songs.groupBy { it._albumGroupingId } // If album types aren't used by the music library (Represented by all songs having - // an album type), there is no point in displaying them. - val enableAlbumTypes = songs.any { it._albumType != Album.Type.ALBUM } + // no album type), there is no point in displaying them. + val enableAlbumTypes = songs.any { it._albumReleaseType != null } if (!enableAlbumTypes) { logD("No distinct album types detected, ignoring them") } @@ -321,7 +317,10 @@ class Indexer { rawName = templateSong._albumName, rawSortName = templateSong._albumSortName, date = templateSong._date, - type = if (enableAlbumTypes) templateSong._albumType else null, + releaseType = + if (enableAlbumTypes) + (templateSong._albumReleaseType ?: ReleaseType.Album(null)) + else null, coverUri = templateSong._albumCoverUri, songs = entry.value, _artistGroupingName = templateSong._artistGroupingName, 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 a57fc0003..4d59d0482 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 @@ -27,23 +27,7 @@ import androidx.annotation.RequiresApi import androidx.core.database.getIntOrNull import androidx.core.database.getStringOrNull import java.io.File -import org.oxycblt.auxio.music.Album -import org.oxycblt.auxio.music.Date -import org.oxycblt.auxio.music.Directory -import org.oxycblt.auxio.music.MimeType -import org.oxycblt.auxio.music.Path -import org.oxycblt.auxio.music.Song -import org.oxycblt.auxio.music.albumCoverUri -import org.oxycblt.auxio.music.audioUri -import org.oxycblt.auxio.music.directoryCompat -import org.oxycblt.auxio.music.mediaStoreVolumeNameCompat -import org.oxycblt.auxio.music.parseId3GenreName -import org.oxycblt.auxio.music.parsePositionNum -import org.oxycblt.auxio.music.queryCursor -import org.oxycblt.auxio.music.storageVolumesCompat -import org.oxycblt.auxio.music.unpackDiscNo -import org.oxycblt.auxio.music.unpackTrackNo -import org.oxycblt.auxio.music.useQuery +import org.oxycblt.auxio.music.* import org.oxycblt.auxio.settings.Settings import org.oxycblt.auxio.util.contentResolverSafe import org.oxycblt.auxio.util.getSystemServiceSafe @@ -340,7 +324,7 @@ abstract class MediaStoreBackend : Indexer.Backend { var albumId: Long? = null, var album: String? = null, var sortAlbum: String? = null, - var albumType: Album.Type? = null, + var releaseType: ReleaseType? = null, var artist: String? = null, var sortArtist: String? = null, var albumArtist: String? = null, @@ -371,7 +355,7 @@ abstract class MediaStoreBackend : Indexer.Backend { _date = date, _albumName = requireNotNull(album) { "Malformed audio: No album name" }, _albumSortName = sortAlbum, - _albumType = albumType ?: Album.Type.ALBUM, + _albumReleaseType = releaseType, _albumCoverUri = requireNotNull(albumId) { "Malformed audio: No album id" }.albumCoverUri, _artistName = artist, diff --git a/app/src/main/java/org/oxycblt/auxio/ui/recycler/ViewHolders.kt b/app/src/main/java/org/oxycblt/auxio/ui/recycler/ViewHolders.kt index 5fc1fe995..cab1dd39a 100644 --- a/app/src/main/java/org/oxycblt/auxio/ui/recycler/ViewHolders.kt +++ b/app/src/main/java/org/oxycblt/auxio/ui/recycler/ViewHolders.kt @@ -107,7 +107,7 @@ private constructor( override fun areItemsTheSame(oldItem: Album, newItem: Album) = oldItem.rawName == newItem.rawName && oldItem.artist.rawName == newItem.artist.rawName && - oldItem.type == newItem.type + oldItem.releaseType == newItem.releaseType } } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 90c85f072..cfb1eb776 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -17,16 +17,30 @@ Songs All Songs - Album Albums - EP + Album + Live album + Remix album + EPs - Single + EP + Live EP + Remix EP + Singles - Compilation + Single + Live single + Remix single + Compilations - Soundtrack + Compilation Soundtracks + Soundtrack + Mixtapes + Mixtape + + Live + Remixes Artist Artists diff --git a/info/FAQ.md b/info/FAQ.md index 88bc7697c..02d37fe37 100644 --- a/info/FAQ.md +++ b/info/FAQ.md @@ -56,9 +56,21 @@ such as "Black Country, New Road" becoming "Black Country". **Auxio does not detect new music:** This is Auxio's default behavior due to limitations regarding android's filesystem APIs. To enable such behavior, turn on "Automatic reloading" in settings. Note that this option does require a persistent notification and higher battery usage. -#### Why are my songs/albums/artists out of order? -Auxio takes sort tags (like `TSOT` or `TITLESORT`) into account when sorting, which could cause items to -appear in unexpected places. If your items do not have sort tags, please file an issue. +#### What does "Ignore MediaStore Tags" even do? +"Ignore MediaStore Tags" configures Auxio's music loader to extract metadata manually using ExoPlayer, which enables the following: +- Fixes for most of the annoying, unfixable issues with `MediaStore` that were elaborated on above +- Sort tag support + - For example, a title written in Japanese could have a phonetic version in their sort tags. This will be used in sorting and search. +- Better date support + - If an artist released several albums in a single year, you can tag your music to have a particular date and time it was released on, and Auxio will + sort the albums accordingly. Examples include `YYYY-MM-DD` or even `YYYY-MM-DD HH:MM:SS` + - Auxio is also capable of supporting original dates. If a remastered album was released in 2020, but the original album was released in 2000, + you can tag your music with `TDOR`/`TORY` for MP3 and `ORIGINALDATE` for Vorbis with the year 2000, and Auxio will display 2000 in-app. +- Release type support from `TXXX:MusicBrainz Release Type`/`GRP1` in MP3 files, and `RELEASETYPE` in OGG/OPUS/FLAC + - Auxio specifically expects something formatted like ` + `, ``, or ``. This should be contained in a single tag. + - ` corresponds to `album`, `ep`, or `single` + - `` corresponds to `compilation`, `soundtrack`, `mixtape`, `live`, or `remix`. The first three will override the primary type, + (ex. `album + compilation` -> "Compilation"), but the latter two will be used to augment the primary type (ex. `album + live` -> "Live Album"). #### Why does search return songs that don't match my query? Auxio actually takes several types of metadata in account in searching: