music: add sort tag support [#172]

Implement sort tag support in the ExoPlayer backend.

Sort tags for grouping is still derived from the templates. Album
artist sort tags are only picked if one is present. System might be
a bit buggy at the moment given that it messes with grouping/sorting
a little.

Resolves #172.
This commit is contained in:
OxygenCobalt 2022-07-13 20:16:22 -06:00
parent f0d6e13b53
commit 969c0c69b7
No known key found for this signature in database
GPG key ID: 37DBE3621FE9AD47
6 changed files with 90 additions and 53 deletions

View file

@ -1,10 +1,11 @@
# Changelog # Changelog
## 2.6.0 ## dev
#### What's New #### What's New
- Added option to ignore `MediaStore` tags, allowing more correct metadata - Added option to ignore `MediaStore` tags, allowing more correct metadata
at the cost of longer loading times at the cost of longer loading times
- Added support for sort tags [#174, dependent on this feature]
- Added Last Added sorting - Added Last Added sorting
## 2.5.0 ## 2.5.0

View file

@ -69,6 +69,7 @@ sealed class MusicParent : Music() {
/** The data object for a song. */ /** The data object for a song. */
data class Song( data class Song(
override val rawName: String, override val rawName: String,
override val rawSortName: String?,
/** The path of this song. */ /** The path of this song. */
val path: Path, val path: Path,
/** The URI linking to this song's file. */ /** The URI linking to this song's file. */
@ -90,12 +91,18 @@ data class Song(
/** Internal field. Do not use. */ /** Internal field. Do not use. */
val _albumName: String, val _albumName: String,
/** Internal field. Do not use. */ /** Internal field. Do not use. */
val _albumSortName: String?,
/** Internal field. Do not use. */
val _albumCoverUri: Uri, val _albumCoverUri: Uri,
/** Internal field. Do not use. */ /** Internal field. Do not use. */
val _artistName: String?, val _artistName: String?,
/** Internal field. Do not use. */ /** Internal field. Do not use. */
val _artistSortName: String?,
/** Internal field. Do not use. */
val _albumArtistName: String?, val _albumArtistName: String?,
/** Internal field. Do not use. */ /** Internal field. Do not use. */
val _albumArtistSortName: String?,
/** Internal field. Do not use. */
val _genreName: String? val _genreName: String?
) : Music() { ) : Music() {
override val id: Long override val id: Long
@ -109,9 +116,6 @@ data class Song(
return result return result
} }
override val rawSortName: String?
get() = null
override fun resolveName(context: Context) = rawName override fun resolveName(context: Context) = rawName
/** The duration of this song, in seconds (rounded down) */ /** The duration of this song, in seconds (rounded down) */
@ -159,6 +163,14 @@ data class Song(
val _artistGroupingName: String? val _artistGroupingName: String?
get() = _albumArtistName ?: _artistName get() = _albumArtistName ?: _artistName
/** Internal field. Do not use. */
val _artistGroupingSortName: String?
get() =
// Only use the album artist sort name if we have one, otherwise ignore it.
_albumArtistName?.let { _albumArtistSortName } ?: _artistName?.let { _artistSortName }
/** Internal field. Do not use. */
/** Internal field. Do not use. */ /** Internal field. Do not use. */
val _isMissingAlbum: Boolean val _isMissingAlbum: Boolean
get() = _album == null get() = _album == null
@ -183,6 +195,7 @@ data class Song(
/** The data object for an album. */ /** The data object for an album. */
data class Album( data class Album(
override val rawName: String, override val rawName: String,
override val rawSortName: String?,
/** The latest year of the songs in this album. Null if none of the songs had metadata. */ /** The latest year of the songs in this album. Null if none of the songs had metadata. */
val year: Int?, val year: Int?,
/** The URI for the cover art corresponding to this album. */ /** The URI for the cover art corresponding to this album. */
@ -191,6 +204,8 @@ data class Album(
override val songs: List<Song>, override val songs: List<Song>,
/** Internal field. Do not use. */ /** Internal field. Do not use. */
val _artistGroupingName: String?, val _artistGroupingName: String?,
/** Internal field. Do not use. */
val _artistGroupingSortName: String?
) : MusicParent() { ) : MusicParent() {
init { init {
for (song in songs) { for (song in songs) {
@ -206,9 +221,6 @@ data class Album(
return result return result
} }
override val rawSortName: String?
get() = null
override fun resolveName(context: Context) = rawName override fun resolveName(context: Context) = rawName
private var _artist: Artist? = null private var _artist: Artist? = null
@ -236,6 +248,7 @@ data class Album(
*/ */
data class Artist( data class Artist(
override val rawName: String?, override val rawName: String?,
override val rawSortName: String?,
/** The albums of this artist. */ /** The albums of this artist. */
val albums: List<Album> val albums: List<Album>
) : MusicParent() { ) : MusicParent() {
@ -248,9 +261,6 @@ data class Artist(
override val id: Long override val id: Long
get() = (rawName ?: MediaStore.UNKNOWN_STRING).hashCode().toLong() get() = (rawName ?: MediaStore.UNKNOWN_STRING).hashCode().toLong()
override val rawSortName: String?
get() = null
override fun resolveName(context: Context) = rawName ?: context.getString(R.string.def_artist) override fun resolveName(context: Context) = rawName ?: context.getString(R.string.def_artist)
/** The songs of this artist. */ /** The songs of this artist. */
@ -265,11 +275,11 @@ data class Genre(override val rawName: String?, override val songs: List<Song>)
} }
} }
override val id: Long
get() = (rawName ?: MediaStore.UNKNOWN_STRING).hashCode().toLong()
override val rawSortName: String? override val rawSortName: String?
get() = null get() = null
override val id: Long
get() = (rawName ?: MediaStore.UNKNOWN_STRING).hashCode().toLong()
override fun resolveName(context: Context) = rawName ?: context.getString(R.string.def_genre) override fun resolveName(context: Context) = rawName ?: context.getString(R.string.def_genre)
} }

View file

@ -206,8 +206,9 @@ class Task(context: Context, private val audio: MediaStoreBackend.Audio) {
} }
private fun populateId3v2(tags: Map<String, String>) { private fun populateId3v2(tags: Map<String, String>) {
// Title // (Sort) Title
tags["TIT2"]?.let { audio.title = it } tags["TIT2"]?.let { audio.title = it }
tags["TSOT"]?.let { audio.sortTitle = it }
// Track, as NN/TT // Track, as NN/TT
tags["TRCK"]?.trackDiscNo?.let { audio.track = it } tags["TRCK"]?.trackDiscNo?.let { audio.track = it }
@ -229,22 +230,26 @@ class Task(context: Context, private val audio: MediaStoreBackend.Audio) {
?: tags["TYER"]?.year) ?: tags["TYER"]?.year)
?.let { audio.year = it } ?.let { audio.year = it }
// Album // (Sort) Album
tags["TALB"]?.let { audio.album = it } tags["TALB"]?.let { audio.album = it }
tags["TSOA"]?.let { audio.sortAlbum = it }
// Artist // (Sort) Artist
tags["TPE1"]?.let { audio.artist = it } tags["TPE1"]?.let { audio.artist = it }
tags["TSOP"]?.let { audio.sortArtist = it }
// Album artist // (Sort) Album artist
tags["TPE2"]?.let { audio.albumArtist = it } tags["TPE2"]?.let { audio.albumArtist = it }
tags["TSO2"]?.let { audio.sortAlbumArtist = it }
// Genre, with the weird ID3 rules. // Genre, with the weird ID3 rules.
tags["TCON"]?.let { audio.genre = it.id3GenreName } tags["TCON"]?.let { audio.genre = it.id3GenreName }
} }
private fun populateVorbis(tags: Map<String, String>) { private fun populateVorbis(tags: Map<String, String>) {
// Title // (Sort) Title
tags["TITLE"]?.let { audio.title = it } tags["TITLE"]?.let { audio.title = it }
tags["TITLESORT"]?.let { audio.sortTitle = it }
// Track. Probably not NN/TT, as TOTALTRACKS handles totals. // Track. Probably not NN/TT, as TOTALTRACKS handles totals.
tags["TRACKNUMBER"]?.plainTrackNo?.let { audio.track = it } tags["TRACKNUMBER"]?.plainTrackNo?.let { audio.track = it }
@ -261,16 +266,17 @@ class Task(context: Context, private val audio: MediaStoreBackend.Audio) {
(tags["ORIGINALDATE"]?.iso8601year ?: tags["DATE"]?.iso8601year ?: tags["YEAR"]?.year) (tags["ORIGINALDATE"]?.iso8601year ?: tags["DATE"]?.iso8601year ?: tags["YEAR"]?.year)
?.let { audio.year = it } ?.let { audio.year = it }
// Album // (Sort) Album
tags["ALBUM"]?.let { audio.album = it } tags["ALBUM"]?.let { audio.album = it }
tags["ALBUMSORT"]?.let { audio.sortAlbum = it }
// Artist // (Sort) Artist
tags["ARTIST"]?.let { audio.artist = it } tags["ARTIST"]?.let { audio.artist = it }
tags["ARTISTSORT"]?.let { audio.sortArtist = it }
// Album artist. This actually comes into two flavors: // (Sort) Album artist.
// 1. ALBUMARTIST, which is the most common tags["ALBUMARTIST"]?.let { audio.albumArtist = it }
// 2. ALBUM ARTIST, which is present on older vorbis tags tags["ALBUMARTISTSORT"]?.let { audio.sortAlbumArtist = it }
(tags["ALBUMARTIST"] ?: tags["ALBUM ARTIST"])?.let { audio.albumArtist = it }
// Genre, no ID3 rules here // Genre, no ID3 rules here
tags["GENRE"]?.let { audio.genre = it } tags["GENRE"]?.let { audio.genre = it }

View file

@ -313,10 +313,12 @@ class Indexer {
albums.add( albums.add(
Album( Album(
rawName = templateSong._albumName, rawName = templateSong._albumName,
rawSortName = templateSong._albumSortName,
year = templateSong._year, year = templateSong._year,
albumCoverUri = templateSong._albumCoverUri, albumCoverUri = templateSong._albumCoverUri,
songs = entry.value, songs = entry.value,
_artistGroupingName = templateSong._artistGroupingName)) _artistGroupingName = templateSong._artistGroupingName,
_artistGroupingSortName = templateSong._artistGroupingSortName))
} }
logD("Successfully built ${albums.size} albums") logD("Successfully built ${albums.size} albums")
@ -335,10 +337,14 @@ class Indexer {
for (entry in albumsByArtist) { for (entry in albumsByArtist) {
// The first album will suffice for template metadata. // The first album will suffice for template metadata.
val templateAlbum = entry.value[0] val templateAlbum = entry.value[0]
artists.add(Artist(rawName = templateAlbum._artistGroupingName, albums = entry.value)) artists.add(
Artist(
rawName = templateAlbum._artistGroupingName,
rawSortName = templateAlbum._artistGroupingSortName,
albums = entry.value))
} }
logD("Successfully built ${artists.size} artists") `logD`("Successfully built ${artists.size} artists")
return artists return artists
} }

View file

@ -238,6 +238,7 @@ abstract class MediaStoreBackend : Indexer.Backend {
open val projection: Array<String> open val projection: Array<String>
get() = get() =
arrayOf( arrayOf(
// These columns are guaranteed to work on all versions of android
MediaStore.Audio.AudioColumns._ID, MediaStore.Audio.AudioColumns._ID,
MediaStore.Audio.AudioColumns.TITLE, MediaStore.Audio.AudioColumns.TITLE,
MediaStore.Audio.AudioColumns.DISPLAY_NAME, MediaStore.Audio.AudioColumns.DISPLAY_NAME,
@ -327,6 +328,7 @@ abstract class MediaStoreBackend : Indexer.Backend {
data class Audio( data class Audio(
var id: Long? = null, var id: Long? = null,
var title: String? = null, var title: String? = null,
var sortTitle: String? = null,
var displayName: String? = null, var displayName: String? = null,
var dir: Directory? = null, var dir: Directory? = null,
var extensionMimeType: String? = null, var extensionMimeType: String? = null,
@ -338,38 +340,50 @@ abstract class MediaStoreBackend : Indexer.Backend {
var disc: Int? = null, var disc: Int? = null,
var year: Int? = null, var year: Int? = null,
var album: String? = null, var album: String? = null,
var sortAlbum: String? = null,
var albumId: Long? = null, var albumId: Long? = null,
var artist: String? = null, var artist: String? = null,
var sortArtist: String? = null,
var albumArtist: String? = null, var albumArtist: String? = null,
var sortAlbumArtist: String? = null,
var genre: String? = null var genre: String? = null
) { ) {
fun toSong() = fun toSong() =
Song( Song(
// Assert that the fields that should always exist are present. I can't confirm that // Assert that the fields that should always exist are present. I can't confirm
// every device provides these fields, but it seems likely that they do. // that
rawName = requireNotNull(title) { "Malformed audio: No title" }, // every device provides these fields, but it seems likely that they do.
path = rawName = requireNotNull(title) { "Malformed audio: No title" },
Path( rawSortName = sortTitle,
name = requireNotNull(displayName) { "Malformed audio: No display name" }, path =
parent = requireNotNull(dir) { "Malformed audio: No parent directory" }), Path(
uri = requireNotNull(id) { "Malformed audio: No id" }.audioUri, name =
mimeType = requireNotNull(displayName) { "Malformed audio: No display name" },
MimeType( parent =
fromExtension = requireNotNull(dir) { "Malformed audio: No parent directory" }),
requireNotNull(extensionMimeType) { "Malformed audio: No mime type" }, uri = requireNotNull(id) { "Malformed audio: No id" }.audioUri,
fromFormat = formatMimeType), mimeType =
size = requireNotNull(size) { "Malformed audio: No size" }, MimeType(
dateAdded = requireNotNull(dateAdded) { "Malformed audio: No date added" }, fromExtension =
durationMs = requireNotNull(duration) { "Malformed audio: No duration" }, requireNotNull(extensionMimeType) {
track = track, "Malformed audio: No mime type"
disc = disc, },
_year = year, fromFormat = formatMimeType),
_albumName = requireNotNull(album) { "Malformed audio: No album name" }, size = requireNotNull(size) { "Malformed audio: No size" },
_albumCoverUri = dateAdded = requireNotNull(dateAdded) { "Malformed audio: No date added" },
requireNotNull(albumId) { "Malformed audio: No album id" }.albumCoverUri, durationMs = requireNotNull(duration) { "Malformed audio: No duration" },
_artistName = artist, track = track,
_albumArtistName = albumArtist, disc = disc,
_genreName = genre) _year = year,
_albumName = requireNotNull(album) { "Malformed audio: No album name" },
_albumSortName = sortAlbum,
_albumCoverUri =
requireNotNull(albumId) { "Malformed audio: No album id" }.albumCoverUri,
_artistName = artist,
_artistSortName = sortArtist,
_albumArtistName = albumArtist,
_albumArtistSortName = sortAlbumArtist,
_genreName = genre)
} }
companion object { companion object {

View file

@ -150,7 +150,7 @@
<string name="set_dirs_mode_include">Include</string> <string name="set_dirs_mode_include">Include</string>
<string name="set_dirs_mode_include_desc">Music will <b>only</b> be loaded from the folders you add.</string> <string name="set_dirs_mode_include_desc">Music will <b>only</b> be loaded from the folders you add.</string>
<string name="set_quality_tags">Ignore MediaStore tags</string> <string name="set_quality_tags">Ignore MediaStore tags</string>
<string name="set_quality_tags_desc">Increases tag quality, but requires longer loading times (Experimental)</string> <string name="set_quality_tags_desc">Increases tag quality, but results in longer loading times (Experimental)</string>
<string name="set_observing">Automatic reloading</string> <string name="set_observing">Automatic reloading</string>
<string name="set_observing_desc">Reload your music library whenever it changes (Experimental)</string> <string name="set_observing_desc">Reload your music library whenever it changes (Experimental)</string>