music: make field utils functions
Make some field utils functions, as they do work.
This commit is contained in:
parent
969c0c69b7
commit
7833ec4460
6 changed files with 81 additions and 112 deletions
|
@ -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
|
||||
|
|
|
@ -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))
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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 }
|
||||
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue