music: make field utils functions

Make some field utils functions, as they do work.
This commit is contained in:
OxygenCobalt 2022-07-14 11:43:28 -06:00
parent 969c0c69b7
commit 7833ec4460
No known key found for this signature in database
GPG key ID: 37DBE3621FE9AD47
6 changed files with 81 additions and 112 deletions

View file

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

View file

@ -132,7 +132,7 @@ class HomeFragment : ViewBindingFragment<FragmentHomeBinding>(), Toolbar.OnMenuI
// ViewPager2 will nominally consume window insets, which will then break the window
// insets applied to the indexing view before API 30. Fix this by overriding the
// callback with a no-op listener.
// callback with a non-consuming listener.
setOnApplyWindowInsetsListener { _, insets -> insets }
}
@ -343,8 +343,6 @@ class HomeFragment : ViewBindingFragment<FragmentHomeBinding>(), Toolbar.OnMenuI
}
private fun handleNavigation(item: Music?) {
// Note: You will want to add a post call to this if you want to re-introduce a collapsing
// toolbar.
when (item) {
is Song ->
findNavController().navigate(HomeFragmentDirections.actionShowAlbum(item.album.id))

View file

@ -37,14 +37,11 @@ sealed class Music : Item() {
abstract val rawSortName: String?
/**
* The name of this item used for sorting. This will first use the sort tag for the item,
* followed by the name without a preceding article (The/A/An). In the case that the item has no
* name, this returns null.
*
* This should not be used outside of sorting and fast-scrolling.
* The name of this item used for sorting.This should not be used outside of sorting and
* fast-scrolling.
*/
val sortName: String?
get() = rawSortName ?: rawName?.withoutArticle
get() = rawSortName ?: rawName?.parseSortName()
/**
* Resolve a name from it's raw form to a form suitable to be shown in a ui. Ex. "unknown" would
@ -275,8 +272,9 @@ data class Genre(override val rawName: String?, override val songs: List<Song>)
}
}
// Sort tags don't make sense on genres
override val rawSortName: String?
get() = null
get() = rawName
override val id: Long
get() = (rawName ?: MediaStore.UNKNOWN_STRING).hashCode().toLong()

View file

@ -55,42 +55,30 @@ val Long.audioUri: Uri
val Long.albumCoverUri: Uri
get() = ContentUris.withAppendedId(EXTERNAL_ALBUM_ART_URI, this)
/**
* Parse out the number field from a field assumed to be NN, where NN is a track number. This is
* most commonly found on vorbis comments. Values of zero will be ignored under the assumption that
* they are invalid.
*/
val String.plainTrackNo: Int?
get() = toIntOrNull()?.nonZeroOrNull()
/**
* Parse out the track number field as if the given Int is formatted as DTTT, where D Is the disc
* and T is the track number. Values of zero will be ignored under the assumption that they are
* invalid.
*/
val Int.packedTrackNo: Int?
get() = mod(1000).nonZeroOrNull()
fun Int.unpackTrackNo() = mod(1000).nonZeroOrNull()
/**
* Parse out the disc number field as if the given Int is formatted as DTTT, where D Is the disc and
* T is the track number. Values of zero will be ignored under the assumption that they are invalid.
*/
val Int.packedDiscNo: Int?
get() = div(1000).nonZeroOrNull()
fun Int.unpackDiscNo() = div(1000).nonZeroOrNull()
/**
* Parse out a plain number from a string. Values of 0 will be ignored under the assumption that
* they are invalid.
*/
fun String.parseNum() = toIntOrNull()?.nonZeroOrNull()
/**
* Parse out the number field from an NN/TT string that is typically found in DISC_NUMBER and
* CD_TRACK_NUMBER. Values of zero will be ignored under the assumption that they are invalid.
*/
val String.trackDiscNo: Int?
get() = split('/', limit = 2)[0].toIntOrNull()?.nonZeroOrNull()
/**
* Parse out a plain year from a string. Values of 0 will be ignored under the assumption that they
* are invalid.
*/
val String.year: Int?
get() = toIntOrNull()?.nonZeroOrNull()
fun String.parsePositionNum() = split('/', limit = 2)[0].toIntOrNull()?.nonZeroOrNull()
/**
* Parse out the year field from a (presumably) ISO-8601-like date. This differs across tag formats
@ -98,8 +86,7 @@ val String.year: Int?
* (...) and thus we can parse the year out by splitting at the first -. Values of 0 will be ignored
* under the assumption that they are invalid.
*/
val String.iso8601year: Int?
get() = split('-', limit = 2)[0].toIntOrNull()?.nonZeroOrNull()
fun String.parseIso8601Year() = split('-', limit = 2)[0].toIntOrNull()?.nonZeroOrNull()
private fun Int.nonZeroOrNull() = if (this > 0) this else null
@ -108,29 +95,19 @@ private fun Int.nonZeroOrNull() = if (this > 0) this else null
* anglo-centric, but it's also a bit of an expected feature in music players, so we implement it
* anyway.
*/
val String.withoutArticle: String
get() {
if (length > 5 && startsWith("the ", ignoreCase = true)) {
return slice(4..lastIndex)
}
if (length > 4 && startsWith("an ", ignoreCase = true)) {
return slice(3..lastIndex)
}
if (length > 3 && startsWith("a ", ignoreCase = true)) {
return slice(2..lastIndex)
}
return this
fun String.parseSortName() =
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
}
/**
* Decodes the genre name from an ID3(v2) constant. See [GENRE_TABLE] for the genre constant map
* that Auxio uses.
*/
val String.id3GenreName: String
get() = parseId3v1Genre() ?: parseId3v2Genre() ?: this
fun String.parseId3GenreName() = parseId3v1Genre() ?: parseId3v2Genre() ?: this
private fun String.parseId3v1Genre(): String? =
when {
@ -158,7 +135,7 @@ private fun String.parseId3v2Genre(): String? {
// ID3v1 tags.
val genreIds = groups[1]
if (genreIds != null && genreIds.value.isNotEmpty()) {
val ids = genreIds.value.substring(1 until genreIds.value.lastIndex).split(")(")
val ids = genreIds.value.substring(1).split(")(")
for (id in ids) {
id.parseId3v1Genre()?.let(genres::add)
}

View file

@ -26,11 +26,10 @@ import com.google.android.exoplayer2.metadata.id3.TextInformationFrame
import com.google.android.exoplayer2.metadata.vorbis.VorbisComment
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.music.audioUri
import org.oxycblt.auxio.music.id3GenreName
import org.oxycblt.auxio.music.iso8601year
import org.oxycblt.auxio.music.plainTrackNo
import org.oxycblt.auxio.music.trackDiscNo
import org.oxycblt.auxio.music.year
import org.oxycblt.auxio.music.parseId3GenreName
import org.oxycblt.auxio.music.parseIso8601Year
import org.oxycblt.auxio.music.parseNum
import org.oxycblt.auxio.music.parsePositionNum
import org.oxycblt.auxio.util.logD
import org.oxycblt.auxio.util.logW
@ -211,10 +210,10 @@ class Task(context: Context, private val audio: MediaStoreBackend.Audio) {
tags["TSOT"]?.let { audio.sortTitle = it }
// Track, as NN/TT
tags["TRCK"]?.trackDiscNo?.let { audio.track = it }
tags["TRCK"]?.parsePositionNum()?.let { audio.track = it }
// Disc, as NN/TT
tags["TPOS"]?.trackDiscNo?.let { audio.disc = it }
tags["TPOS"]?.parsePositionNum()?.let { audio.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
@ -225,9 +224,9 @@ class Task(context: Context, private val audio: MediaStoreBackend.Audio) {
// 3. ID3v2.4 Release Date, as it is the second most common date type
// 4. ID3v2.3 Original Date, as it is like #1
// 5. ID3v2.3 Release Year, as it is the most common date type
(tags["TDOR"]?.iso8601year
?: tags["TDRC"]?.iso8601year ?: tags["TDRL"]?.iso8601year ?: tags["TORY"]?.year
?: tags["TYER"]?.year)
(tags["TDOR"]?.parseIso8601Year()
?: tags["TDRC"]?.parseIso8601Year() ?: tags["TDRL"]?.parseIso8601Year()
?: tags["TORY"]?.parseNum() ?: tags["TYER"]?.parseNum())
?.let { audio.year = it }
// (Sort) Album
@ -243,7 +242,7 @@ class Task(context: Context, private val audio: MediaStoreBackend.Audio) {
tags["TSO2"]?.let { audio.sortAlbumArtist = it }
// Genre, with the weird ID3 rules.
tags["TCON"]?.let { audio.genre = it.id3GenreName }
tags["TCON"]?.let { audio.genre = it.parseId3GenreName() }
}
private fun populateVorbis(tags: Map<String, String>) {
@ -252,10 +251,10 @@ class Task(context: Context, private val audio: MediaStoreBackend.Audio) {
tags["TITLESORT"]?.let { audio.sortTitle = it }
// Track. Probably not NN/TT, as TOTALTRACKS handles totals.
tags["TRACKNUMBER"]?.plainTrackNo?.let { audio.track = it }
tags["TRACKNUMBER"]?.parseNum()?.let { audio.track = it }
// Disc. Probably not NN/TT, as TOTALDISCS handles totals.
tags["DISCNUMBER"]?.plainTrackNo?.let { audio.disc = it }
tags["DISCNUMBER"]?.parseNum()?.let { audio.disc = it }
// Vorbis dates are less complicated, but there are still several types
// Our hierarchy for dates is as such:
@ -263,7 +262,8 @@ class Task(context: Context, private val audio: MediaStoreBackend.Audio) {
// 2. Date, as it is the most common date type
// 3. Year, as old vorbis tags tended to use this (I know this because it's the only
// tag that android supports, so it must be 15 years old or more!)
(tags["ORIGINALDATE"]?.iso8601year ?: tags["DATE"]?.iso8601year ?: tags["YEAR"]?.year)
(tags["ORIGINALDATE"]?.parseIso8601Year()
?: tags["DATE"]?.parseIso8601Year() ?: tags["YEAR"]?.parseNum())
?.let { audio.year = it }
// (Sort) Album
@ -274,7 +274,7 @@ class Task(context: Context, private val audio: MediaStoreBackend.Audio) {
tags["ARTIST"]?.let { audio.artist = it }
tags["ARTISTSORT"]?.let { audio.sortArtist = it }
// (Sort) Album artist.
// (Sort) Album artist
tags["ALBUMARTIST"]?.let { audio.albumArtist = it }
tags["ALBUMARTISTSORT"]?.let { audio.sortAlbumArtist = it }

View file

@ -34,13 +34,13 @@ 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.id3GenreName
import org.oxycblt.auxio.music.mediaStoreVolumeNameCompat
import org.oxycblt.auxio.music.packedDiscNo
import org.oxycblt.auxio.music.packedTrackNo
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.trackDiscNo
import org.oxycblt.auxio.music.unpackDiscNo
import org.oxycblt.auxio.music.unpackTrackNo
import org.oxycblt.auxio.music.useQuery
import org.oxycblt.auxio.settings.Settings
import org.oxycblt.auxio.util.contentResolverSafe
@ -203,7 +203,7 @@ abstract class MediaStoreBackend : Indexer.Backend {
// format a genre was derived from, we have to treat them like they are ID3
// genres, even when they might not be.
val id = genreCursor.getLong(idIndex)
val name = (genreCursor.getStringOrNull(nameIndex) ?: continue).id3GenreName
val name = (genreCursor.getStringOrNull(nameIndex) ?: continue).parseId3GenreName()
context.contentResolverSafe.useQuery(
MediaStore.Audio.Genres.Members.getContentUri(VOLUME_EXTERNAL, id),
@ -350,40 +350,36 @@ abstract class MediaStoreBackend : Indexer.Backend {
) {
fun toSong() =
Song(
// Assert that the fields that should always exist are present. I can't confirm
// that
// every device provides these fields, but it seems likely that they do.
rawName = requireNotNull(title) { "Malformed audio: No title" },
rawSortName = sortTitle,
path =
Path(
name =
requireNotNull(displayName) { "Malformed audio: No display name" },
parent =
requireNotNull(dir) { "Malformed audio: No parent directory" }),
uri = requireNotNull(id) { "Malformed audio: No id" }.audioUri,
mimeType =
MimeType(
fromExtension =
requireNotNull(extensionMimeType) {
"Malformed audio: No mime type"
},
fromFormat = formatMimeType),
size = requireNotNull(size) { "Malformed audio: No size" },
dateAdded = requireNotNull(dateAdded) { "Malformed audio: No date added" },
durationMs = requireNotNull(duration) { "Malformed audio: No duration" },
track = track,
disc = disc,
_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)
// Assert that the fields that should always exist are present. I can't confirm
// that
// every device provides these fields, but it seems likely that they do.
rawName = requireNotNull(title) { "Malformed audio: No title" },
rawSortName = sortTitle,
path =
Path(
name = requireNotNull(displayName) { "Malformed audio: No display name" },
parent = requireNotNull(dir) { "Malformed audio: No parent directory" }),
uri = requireNotNull(id) { "Malformed audio: No id" }.audioUri,
mimeType =
MimeType(
fromExtension =
requireNotNull(extensionMimeType) { "Malformed audio: No mime type" },
fromFormat = formatMimeType),
size = requireNotNull(size) { "Malformed audio: No size" },
dateAdded = requireNotNull(dateAdded) { "Malformed audio: No date added" },
durationMs = requireNotNull(duration) { "Malformed audio: No duration" },
track = track,
disc = disc,
_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 {
@ -469,8 +465,8 @@ class Api21MediaStoreBackend : MediaStoreBackend() {
val rawTrack = cursor.getIntOrNull(trackIndex)
if (rawTrack != null) {
rawTrack.packedTrackNo?.let { audio.track = it }
rawTrack.packedDiscNo?.let { audio.disc = it }
rawTrack.unpackTrackNo()?.let { audio.track = it }
rawTrack.unpackDiscNo()?.let { audio.disc = it }
}
return audio
@ -555,8 +551,8 @@ open class Api29MediaStoreBackend : BaseApi29MediaStoreBackend() {
// Use the old field instead.
val rawTrack = cursor.getIntOrNull(trackIndex)
if (rawTrack != null) {
rawTrack.packedTrackNo?.let { audio.track = it }
rawTrack.packedDiscNo?.let { audio.disc = it }
rawTrack.unpackTrackNo()?.let { audio.track = it }
rawTrack.unpackDiscNo()?.let { audio.disc = it }
}
return audio
@ -594,8 +590,8 @@ class Api30MediaStoreBackend : BaseApi29MediaStoreBackend() {
// N is the number and T is the total. Parse the number while leaving out the
// total, as we have no use for it.
cursor.getStringOrNull(trackIndex)?.trackDiscNo?.let { audio.track = it }
cursor.getStringOrNull(discIndex)?.trackDiscNo?.let { audio.disc = it }
cursor.getStringOrNull(trackIndex)?.parsePositionNum()?.let { audio.track = it }
cursor.getStringOrNull(discIndex)?.parsePositionNum()?.let { audio.disc = it }
return audio
}