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:
parent
ad45b3edb3
commit
9ca4f70315
8 changed files with 66 additions and 29 deletions
|
@ -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" />
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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) =
|
||||
|
|
|
@ -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 }
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
Loading…
Reference in a new issue