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.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.WAKE_LOCK" /> <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 <uses-permission
android:name="android.permission.ACCESS_NETWORK_STATE" android:name="android.permission.ACCESS_NETWORK_STATE"
tools:node="remove" /> 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 * 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. * 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 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. * or reject valid-ish dates.
* *
* Date instances are immutable and their internal implementation is hidden. To instantiate one, use * 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 * [from] or [from]. The string representation of a Date is RFC 3339, with granular position
* position depending on the presence of particular tokens. * depending on the presence of particular tokens.
* *
* Please, **Do not use this for anything important related to time.** I cannot stress this enough. * 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. * 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() override fun toString() = StringBuilder().appendDate().toString()
private fun StringBuilder.appendDate(): StringBuilder { 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(year.toFixedString(4))
append("-${(month ?: return this).toFixedString(2)}") append("-${(month ?: return this).toFixedString(2)}")
append("-${(day ?: return this).toFixedString(2)}") append("-${(day ?: return this).toFixedString(2)}")
append("T${(hour ?: return this).toFixedString(2)}") append("T${(hour ?: return this).toFixedString(2)}")
append(":${(minute ?: return this).toFixedString(2)}") append(":${(minute ?: return this.append('Z')).toFixedString(2)}")
append(":${(second ?: return this).toFixedString(2)}Z") append(":${(second ?: return this.append('Z')).toFixedString(2)}")
return this return this.append('Z')
} }
private fun Int.toFixedString(len: Int) = toString().padStart(len, '0') 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( Regex(
"""^(\d{4,})([-.](\d{2})([-.](\d{2})([T ](\d{2})([:.](\d{2})([:.](\d{2}))?)?)?)?)?$""") """^(\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? { fun from(year: Int, month: Int, day: Int) = fromTokens(listOf(year, month, day))
val groups = (ISO8601_REGEX.matchEntire(timestamp) ?: return null).groupValues
val tokens = mutableListOf<Int>()
populateTokens(groups, tokens)
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 null
} }
return Date(tokens) return Date(out)
} }
private fun populateTokens(groups: List<String>, tokens: MutableList<Int>) { private fun validateTokens(src: List<Int>, dst: MutableList<Int>) {
tokens.add(groups.getOrNull(1)?.toIntOrNull()?.nonZeroOrNull() ?: return) dst.add(src.getOrNull(0)?.nonZeroOrNull() ?: return)
tokens.add(groups.getOrNull(3)?.toIntOrNull()?.inRangeOrNull(1..12) ?: return) dst.add(src.getOrNull(1)?.inRangeOrNull(1..12) ?: return)
tokens.add(groups.getOrNull(5)?.toIntOrNull()?.inRangeOrNull(1..31) ?: return) dst.add(src.getOrNull(2)?.inRangeOrNull(1..31) ?: return)
tokens.add(groups.getOrNull(7)?.toIntOrNull()?.inRangeOrNull(0..23) ?: return) dst.add(src.getOrNull(3)?.inRangeOrNull(0..23) ?: return)
tokens.add(groups.getOrNull(9)?.toIntOrNull()?.inRangeOrNull(0..59) ?: return) dst.add(src.getOrNull(4)?.inRangeOrNull(0..59) ?: return)
tokens.add(groups.getOrNull(11)?.toIntOrNull()?.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() fun String.parsePositionNum() = split('/', limit = 2)[0].toIntOrNull()?.nonZeroOrNull()
/** Parse a plain year from the field into a [Date]. */ /** 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]. */ /** 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. */ /** Shortcut to resolve a year from a nullable date. Will return "No Date" if it is null. */
fun Date?.resolveYear(context: Context) = fun Date?.resolveYear(context: Context) =

View file

@ -19,11 +19,13 @@ package org.oxycblt.auxio.music.system
import android.content.Context import android.content.Context
import android.database.Cursor import android.database.Cursor
import androidx.core.text.isDigitsOnly
import com.google.android.exoplayer2.MediaItem import com.google.android.exoplayer2.MediaItem
import com.google.android.exoplayer2.MetadataRetriever import com.google.android.exoplayer2.MetadataRetriever
import com.google.android.exoplayer2.metadata.Metadata import com.google.android.exoplayer2.metadata.Metadata
import com.google.android.exoplayer2.metadata.id3.TextInformationFrame import com.google.android.exoplayer2.metadata.id3.TextInformationFrame
import com.google.android.exoplayer2.metadata.vorbis.VorbisComment 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.Song
import org.oxycblt.auxio.music.audioUri import org.oxycblt.auxio.music.audioUri
import org.oxycblt.auxio.music.parseId3GenreName 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 // 5. ID3v2.3 Release Year, as it is the most common date type
(tags["TDOR"]?.parseTimestamp() (tags["TDOR"]?.parseTimestamp()
?: tags["TDRC"]?.parseTimestamp() ?: tags["TDRL"]?.parseTimestamp() ?: tags["TDRC"]?.parseTimestamp() ?: tags["TDRL"]?.parseTimestamp()
?: tags["TORY"]?.parseYear() ?: tags["TYER"]?.parseYear()) ?: parseId3v23Date(tags))
?.let { audio.date = it } ?.let { audio.date = it }
// (Sort) Album // (Sort) Album
@ -245,6 +247,27 @@ class Task(context: Context, private val audio: MediaStoreBackend.Audio) {
tags["TCON"]?.let { audio.genre = it.parseId3GenreName() } 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>) { private fun populateVorbis(tags: Map<String, String>) {
// (Sort) Title // (Sort) Title
tags["TITLE"]?.let { audio.title = it } tags["TITLE"]?.let { audio.title = it }

View file

@ -293,7 +293,7 @@ abstract class MediaStoreBackend : Indexer.Backend {
audio.displayName = cursor.getStringOrNull(displayNameIndex) audio.displayName = cursor.getStringOrNull(displayNameIndex)
audio.duration = cursor.getLong(durationIndex) 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 // 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 // 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 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 title = song.resolveName(context)
val artist = song.resolveIndividualArtistName(context) val artist = song.resolveIndividualArtistName(context)
val builder = 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 // 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. // without the notification being eaten by rate-limiting.
provider.load( provider.load(
song, song,
object : BitmapProvider.Target { object : BitmapProvider.Target {

View file

@ -65,7 +65,7 @@ class SongViewHolder private constructor(private val binding: ItemSongBinding) :
object : SimpleItemCallback<Song>() { object : SimpleItemCallback<Song>() {
override fun areItemsTheSame(oldItem: Song, newItem: Song) = override fun areItemsTheSame(oldItem: Song, newItem: Song) =
oldItem.rawName == newItem.rawName && 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 unique, non-subjective fields of the music data. Attempting to use it as a `MediaStore` ID will
result in errors. result in errors.
- Any field or method beginning with `_` is off-limits. These fields are meant for use within - 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 simplify the loading process, as there is no reason to remove internal fields given that it won't
free memory. free memory.
- `rawName` is used when doing internal work, such as saving music data or diffing items - `rawName` is used when doing internal work, such as saving music data or diffing items