music: add id3v2.3 full date support [#159]

Add support for the ID3v2.3 TDAT and TIME frames to the ExoPlayer
parser.
This commit is contained in:
OxygenCobalt 2022-07-17 14:15:36 -06:00
parent ad45b3edb3
commit 9ca4f70315
No known key found for this signature in database
GPG key ID: 37DBE3621FE9AD47
8 changed files with 66 additions and 29 deletions

View file

@ -7,7 +7,7 @@
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<!-- Work around ExoPlayer requiring unnecessary permissions -->
<!-- Work around ExoPlayer requiring network permissions we do not use -->
<uses-permission
android:name="android.permission.ACCESS_NETWORK_STATE"
tools:node="remove" />

View file

@ -139,7 +139,7 @@ data class Song(
* The raw artist name for this song in particular. First uses the artist tag, and then falls
* back to the album artist tag (i.e parent artist name). Null if name is unknown.
*/
val individualRawArtistName: String?
val individualArtistRawName: String?
get() = _artistName ?: album.artist.rawName
/**
@ -301,8 +301,8 @@ data class Genre(override val rawName: String?, override val songs: List<Song>)
* or reject valid-ish dates.
*
* Date instances are immutable and their internal implementation is hidden. To instantiate one, use
* [fromYear] or [parseTimestamp]. The string representation of a Date is RFC 3339, with granular
* position depending on the presence of particular tokens.
* [from] or [from]. The string representation of a Date is RFC 3339, with granular position
* depending on the presence of particular tokens.
*
* Please, **Do not use this for anything important related to time.** I cannot stress this enough.
* This class will blow up if you try to do that.
@ -364,14 +364,14 @@ class Date private constructor(private val tokens: List<Int>) : Comparable<Date>
override fun toString() = StringBuilder().appendDate().toString()
private fun StringBuilder.appendDate(): StringBuilder {
// I assume RFC 3339 allows partial precision, i.e YYYY-MM, but I'm not sure.
// RFC 3339 does not allow partial precision.
append(year.toFixedString(4))
append("-${(month ?: return this).toFixedString(2)}")
append("-${(day ?: return this).toFixedString(2)}")
append("T${(hour ?: return this).toFixedString(2)}")
append(":${(minute ?: return this).toFixedString(2)}")
append(":${(second ?: return this).toFixedString(2)}Z")
return this
append(":${(minute ?: return this.append('Z')).toFixedString(2)}")
append(":${(second ?: return this.append('Z')).toFixedString(2)}")
return this.append('Z')
}
private fun Int.toFixedString(len: Int) = toString().padStart(len, '0')
@ -381,27 +381,40 @@ class Date private constructor(private val tokens: List<Int>) : Comparable<Date>
Regex(
"""^(\d{4,})([-.](\d{2})([-.](\d{2})([T ](\d{2})([:.](\d{2})([:.](\d{2}))?)?)?)?)?$""")
fun fromYear(year: Int) = year.nonZeroOrNull()?.let { Date(listOf(it)) }
fun from(year: Int) = fromTokens(listOf(year))
fun parseTimestamp(timestamp: String): Date? {
val groups = (ISO8601_REGEX.matchEntire(timestamp) ?: return null).groupValues
val tokens = mutableListOf<Int>()
populateTokens(groups, tokens)
fun from(year: Int, month: Int, day: Int) = fromTokens(listOf(year, month, day))
if (tokens.isEmpty()) {
fun from(year: Int, month: Int, day: Int, hour: Int, minute: Int) =
fromTokens(listOf(year, month, day, hour, minute))
fun from(timestamp: String): Date? {
val groups =
(ISO8601_REGEX.matchEntire(timestamp) ?: return null)
.groupValues.mapIndexedNotNull { index, s ->
if (index % 2 != 0) s.toIntOrNull() else null
}
return fromTokens(groups)
}
private fun fromTokens(tokens: List<Int>): Date? {
val out = mutableListOf<Int>()
validateTokens(tokens, out)
if (out.isEmpty()) {
return null
}
return Date(tokens)
return Date(out)
}
private fun populateTokens(groups: List<String>, tokens: MutableList<Int>) {
tokens.add(groups.getOrNull(1)?.toIntOrNull()?.nonZeroOrNull() ?: return)
tokens.add(groups.getOrNull(3)?.toIntOrNull()?.inRangeOrNull(1..12) ?: return)
tokens.add(groups.getOrNull(5)?.toIntOrNull()?.inRangeOrNull(1..31) ?: return)
tokens.add(groups.getOrNull(7)?.toIntOrNull()?.inRangeOrNull(0..23) ?: return)
tokens.add(groups.getOrNull(9)?.toIntOrNull()?.inRangeOrNull(0..59) ?: return)
tokens.add(groups.getOrNull(11)?.toIntOrNull()?.inRangeOrNull(0..59) ?: return)
private fun validateTokens(src: List<Int>, dst: MutableList<Int>) {
dst.add(src.getOrNull(0)?.nonZeroOrNull() ?: return)
dst.add(src.getOrNull(1)?.inRangeOrNull(1..12) ?: return)
dst.add(src.getOrNull(2)?.inRangeOrNull(1..31) ?: return)
dst.add(src.getOrNull(3)?.inRangeOrNull(0..23) ?: return)
dst.add(src.getOrNull(4)?.inRangeOrNull(0..59) ?: return)
dst.add(src.getOrNull(5)?.inRangeOrNull(0..59) ?: return)
}
}
}

View file

@ -78,10 +78,10 @@ fun Int.unpackDiscNo() = div(1000).nonZeroOrNull()
fun String.parsePositionNum() = split('/', limit = 2)[0].toIntOrNull()?.nonZeroOrNull()
/** Parse a plain year from the field into a [Date]. */
fun String.parseYear() = toIntOrNull()?.let(Date::fromYear)
fun String.parseYear() = toIntOrNull()?.let(Date::from)
/** Parse an ISO-8601 time-stamp from this field into a [Date]. */
fun String.parseTimestamp() = Date.parseTimestamp(this)
fun String.parseTimestamp() = Date.from(this)
/** Shortcut to resolve a year from a nullable date. Will return "No Date" if it is null. */
fun Date?.resolveYear(context: Context) =

View file

@ -19,11 +19,13 @@ package org.oxycblt.auxio.music.system
import android.content.Context
import android.database.Cursor
import androidx.core.text.isDigitsOnly
import com.google.android.exoplayer2.MediaItem
import com.google.android.exoplayer2.MetadataRetriever
import com.google.android.exoplayer2.metadata.Metadata
import com.google.android.exoplayer2.metadata.id3.TextInformationFrame
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.parseId3GenreName
@ -226,7 +228,7 @@ class Task(context: Context, private val audio: MediaStoreBackend.Audio) {
// 5. ID3v2.3 Release Year, as it is the most common date type
(tags["TDOR"]?.parseTimestamp()
?: tags["TDRC"]?.parseTimestamp() ?: tags["TDRL"]?.parseTimestamp()
?: tags["TORY"]?.parseYear() ?: tags["TYER"]?.parseYear())
?: parseId3v23Date(tags))
?.let { audio.date = it }
// (Sort) Album
@ -245,6 +247,27 @@ class Task(context: Context, private val audio: MediaStoreBackend.Audio) {
tags["TCON"]?.let { audio.genre = it.parseId3GenreName() }
}
private fun parseId3v23Date(tags: Map<String, String>): Date? {
val year = tags["TORY"]?.toIntOrNull() ?: tags["TYER"]?.toIntOrNull() ?: return null
val mmdd = tags["TDAT"]
return if (mmdd != null && mmdd.length == 4 && mmdd.isDigitsOnly()) {
val mm = mmdd.substring(0..1).toInt()
val dd = mmdd.substring(2..3).toInt()
val hhmi = tags["TIME"]
if (hhmi != null && hhmi.length == 4 && hhmi.isDigitsOnly()) {
val hh = hhmi.substring(0..1).toInt()
val mi = hhmi.substring(2..3).toInt()
Date.from(year, mm, dd, hh, mi)
} else {
Date.from(year, mm, dd)
}
} else {
return Date.from(year)
}
}
private fun populateVorbis(tags: Map<String, String>) {
// (Sort) Title
tags["TITLE"]?.let { audio.title = it }

View file

@ -293,7 +293,7 @@ abstract class MediaStoreBackend : Indexer.Backend {
audio.displayName = cursor.getStringOrNull(displayNameIndex)
audio.duration = cursor.getLong(durationIndex)
audio.date = cursor.getIntOrNull(yearIndex)?.let(Date::fromYear)
audio.date = cursor.getIntOrNull(yearIndex)?.let(Date::from)
// A non-existent album name should theoretically be the name of the folder it contained
// in, but in practice it is more often "0" (as in /storage/emulated/0), even when it the

View file

@ -109,6 +109,8 @@ class MediaSessionComponent(
return
}
// We would leave the artist field null if it didn't exist and let downstream consumers
// handle it, but that would break the notification display.
val title = song.resolveName(context)
val artist = song.resolveIndividualArtistName(context)
val builder =
@ -152,7 +154,6 @@ class MediaSessionComponent(
//
// Neither of these are good, but 1 is the only one that will work on all versions
// without the notification being eaten by rate-limiting.
provider.load(
song,
object : BitmapProvider.Target {

View file

@ -65,7 +65,7 @@ class SongViewHolder private constructor(private val binding: ItemSongBinding) :
object : SimpleItemCallback<Song>() {
override fun areItemsTheSame(oldItem: Song, newItem: Song) =
oldItem.rawName == newItem.rawName &&
oldItem.individualRawArtistName == oldItem.individualRawArtistName
oldItem.individualArtistRawName == oldItem.individualArtistRawName
}
}
}

View file

@ -129,7 +129,7 @@ Things to keep in mind while working with music data:
unique, non-subjective fields of the music data. Attempting to use it as a `MediaStore` ID will
result in errors.
- Any field or method beginning with `_` is off-limits. These fields are meant for use within
`MusicLoader` and generally provide poor UX to the user. The only reason they are public is to
the indexer and generally provide poor UX to the user. The only reason they are public is to
simplify the loading process, as there is no reason to remove internal fields given that it won't
free memory.
- `rawName` is used when doing internal work, such as saving music data or diffing items