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.
This commit is contained in:
OxygenCobalt 2022-07-20 10:40:41 -06:00
parent 36bb729e67
commit 19a0728e5b
No known key found for this signature in database
GPG key ID: 37DBE3621FE9AD47
11 changed files with 220 additions and 116 deletions

View file

@ -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.

View file

@ -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<Item>(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<ArtistAlbumGrouping> {
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
}
}
}

View file

@ -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
}
}
}

View file

@ -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 <primary> + <secondary>
// 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<Int>) : Comparable<Date>
}
}
}
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<String>.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)
}
}
}
}

View file

@ -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")

View file

@ -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 }
}
/**

View file

@ -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,

View file

@ -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,

View file

@ -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
}
}
}

View file

@ -17,16 +17,30 @@
<string name="lbl_songs">Songs</string>
<string name="lbl_all_songs">All Songs</string>
<string name="lbl_album">Album</string>
<string name="lbl_albums">Albums</string>
<string name="lbl_ep">EP</string>
<string name="lbl_album">Album</string>
<string name="lbl_album_live">Live album</string>
<string name="lbl_album_remix">Remix album</string>
<string name="lbl_eps">EPs</string>
<string name="lbl_single">Single</string>
<string name="lbl_ep">EP</string>
<string name="lbl_ep_live">Live EP</string>
<string name="lbl_ep_remix">Remix EP</string>
<string name="lbl_singles">Singles</string>
<string name="lbl_compilation">Compilation</string>
<string name="lbl_single">Single</string>
<string name="lbl_single_live">Live single</string>
<string name="lbl_single_remix">Remix single</string>
<string name="lbl_compilations">Compilations</string>
<string name="lbl_soundtrack">Soundtrack</string>
<string name="lbl_compilation">Compilation</string>
<string name="lbl_soundtracks">Soundtracks</string>
<string name="lbl_soundtrack">Soundtrack</string>
<string name="lbl_mixtapes">Mixtapes</string>
<string name="lbl_mixtape">Mixtape</string>
<string name="lbl_live_group">Live</string>
<string name="lbl_remix_group">Remixes</string>
<string name="lbl_artist">Artist</string>
<string name="lbl_artists">Artists</string>

View file

@ -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 `<primary> + <secondary>`, `<primary>`, or `<secondary>`. This should be contained in a single tag.
- `<primary`> corresponds to `album`, `ep`, or `single`
- `<secondary>` 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: