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.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" />
|
||||||
|
|
|
@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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) =
|
||||||
|
|
|
@ -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 }
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
||||||
|
|
Loading…
Reference in a new issue