music: add support for id3v2.4 multi value tags

Add support for ID3v2.4-style multi-value tags separated by a null
terminator.

This temporarily removes most other forms of separator parsing in the
app. My plan is to reunify it under a new separator setting that allows
the user to select how multi-value tags are separated in their library.
Separator parsing tends to be too destructive by default, so this tends
to be a good option overall.

This commit does require ExoPlayer to be forked once again to add
ID3v2.4 separator support.
This commit is contained in:
Alexander Capehart 2022-09-07 20:54:20 -06:00
parent 2690e8343a
commit 2033e2cb1f
No known key found for this signature in database
GPG key ID: 37DBE3621FE9AD47
5 changed files with 68 additions and 103 deletions

View file

@ -56,9 +56,6 @@ android {
}
}
afterEvaluate {
preDebugBuild.dependsOn spotlessApply
}
dependencies {
// Kotlin
@ -103,8 +100,11 @@ dependencies {
// Exoplayer
// WARNING: THE EXOPLAYER VERSION MUST BE KEPT IN LOCK-STEP WITH THE PRE-BUILD SCRIPT.
// IF NOT, VERY UNFRIENDLY BUILD FAILURES AND CRASHES MAY ENSUE.
implementation "com.google.android.exoplayer:exoplayer-core:2.18.1"
implementation("com.google.android.exoplayer:exoplayer-core:2.18.1") {
exclude group: "com.google.android.exoplayer", module: "exoplayer-extractor"
}
implementation fileTree(dir: "libs", include: ["library-*.aar"])
implementation fileTree(dir: "libs", include: ["extension-*.aar"])
// Image loading

View file

@ -34,8 +34,6 @@ import org.oxycblt.auxio.R
import org.oxycblt.auxio.ui.Sort
import org.oxycblt.auxio.ui.recycler.Item
import org.oxycblt.auxio.util.inRangeOrNull
import org.oxycblt.auxio.util.logE
import org.oxycblt.auxio.util.msToSecs
import org.oxycblt.auxio.util.nonZeroOrNull
import org.oxycblt.auxio.util.unlikelyToBeNull
@ -86,75 +84,31 @@ sealed class Music : Item {
* @author OxygenCobalt
*/
@Parcelize
class UID
private constructor(private val datatype: String, private val isMusicBrainz: Boolean, private val uuid: UUID) :
Parcelable {
// TODO: Formalize datatype and isMusicBrainz more
class UID private constructor(private val tag: String, private val uuid: UUID) : Parcelable {
// Cache the hashCode for speed
@IgnoredOnParcel private val hashCode: Int
init {
var result = datatype.hashCode()
result = 31 * result + isMusicBrainz.hashCode()
result = 31 * result + uuid.hashCode()
hashCode = result
}
@IgnoredOnParcel private val hashCode = 31 * tag.hashCode() + uuid.hashCode()
override fun hashCode() = hashCode
override fun equals(other: Any?) =
other is UID &&
datatype == other.datatype &&
isMusicBrainz == other.isMusicBrainz &&
uuid == other.uuid
override fun equals(other: Any?) = other is UID && tag == other.tag && uuid == other.uuid
override fun toString() =
"$datatype/${if (isMusicBrainz) FORMAT_MUSICBRAINZ else FORMAT_AUXIO}:$uuid"
override fun toString() = "$tag:$uuid"
companion object {
const val FORMAT_AUXIO = "auxio"
const val FORMAT_MUSICBRAINZ = "musicbrainz"
/** Parse a [UID] from the string [uid]. Returns null if not valid. */
fun fromString(uid: String): UID? {
val split = uid.split(':', limit = 2)
if (split.size != 2) {
logE("Invalid uid: Malformed structure")
}
val namespace = split[0].split('/', limit = 2)
if (namespace.size != 2) {
logE("Invalid uid: Malformed namespace")
return null
}
val datatype = namespace[0]
val isMusicBrainz =
when (namespace[1]) {
FORMAT_AUXIO -> false
FORMAT_MUSICBRAINZ -> true
else -> {
logE("Invalid uid: Malformed uuid format")
return null
}
}
val uuid =
try {
UUID.fromString(split[1])
} catch (e: IllegalArgumentException) {
logE("Invalid uid: Malformed uuid")
return null
}
return UID(datatype, isMusicBrainz, uuid)
return UID(tag = split[0], split[1].toUuid() ?: return null)
}
/**
* Make a UUID derived from the MD5 hash of the data digested in [updates].
*
* This is considered the "auxio" uuid format.
* This is Auxio's UID format.
*/
fun hashed(clazz: KClass<*>, updates: MessageDigest.() -> Unit): UID {
// Auxio hashes consist of the MD5 hash of the non-subjective, consistent
@ -162,7 +116,8 @@ sealed class Music : Item {
val digest = MessageDigest.getInstance("MD5")
updates(digest)
val uuid = digest.digest().toUuid()
return UID(unlikelyToBeNull(clazz.simpleName).lowercase(), false, uuid)
val tag = "auxio.${unlikelyToBeNull(clazz.simpleName).lowercase()}"
return UID(tag, uuid)
}
}
}
@ -298,9 +253,6 @@ class Song constructor(private val raw: Raw) : Music() {
update(track)
update(disc)
// Hashing by seconds makes the song more resilient to trimming
update(durationMs.msToSecs())
}
}
@ -580,9 +532,9 @@ fun ByteArray.toUuid(): UUID {
* nature of tag formats. Thus, it's better to use an analogous data structure that will not mangle
* or reject valid-ish dates.
*
* Date instances are immutable and their implementation is hidden. To instantiate one, use
* [from]. The string representation of a Date is RFC 3339, with granular position depending on the
* presence of particular tokens.
* Date instances are immutable and their implementation is hidden. To instantiate one, use [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 code will blow up if you try to do that.
@ -776,8 +728,6 @@ sealed class ReleaseType {
}
companion object {
fun parse(type: String) = parse(type.split('+'))
fun parse(types: List<String>): ReleaseType {
val primary = types[0].trim()

View file

@ -26,6 +26,7 @@ import android.provider.MediaStore
import androidx.core.text.isDigitsOnly
import org.oxycblt.auxio.R
import org.oxycblt.auxio.util.nonZeroOrNull
import java.util.UUID
/** Shortcut for making a [ContentResolver] query with less superfluous arguments. */
fun ContentResolver.queryCursor(
@ -58,6 +59,8 @@ val Long.audioUri: Uri
val Long.albumCoverUri: Uri
get() = ContentUris.withAppendedId(EXTERNAL_ALBUM_ART_URI, this)
fun String.toUuid() = try { UUID.fromString(this) } catch (e: IllegalArgumentException) { null }
/**
* 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
@ -100,9 +103,6 @@ fun String.parseSortName() =
else -> this
}
/** Shortcut to parse an [ReleaseType] from a string */
fun String.parseReleaseType() = ReleaseType.parse(this)
/** Shortcut to parse a [ReleaseType] from a list of strings */
fun List<String>.parseReleaseType() = ReleaseType.parse(this)
@ -110,8 +110,14 @@ fun List<String>.parseReleaseType() = ReleaseType.parse(this)
* Decodes the genre name from an ID3(v2) constant. See [GENRE_TABLE] for the genre constant map
* that Auxio uses.
*/
fun String.parseId3GenreName() =
parseId3v1Genre()?.let { listOf(it) } ?: parseId3v2Genre() ?: listOf(this)
fun String.parseId3GenreName() = parseId3v1Genre()?.let { listOf(it) } ?: parseId3v2Genre() ?: listOf(this)
/**
* Decodes the genre names from an ID3(v2) constant. See [GENRE_TABLE] for the genre constant map
* that Auxio uses.
*/
fun List<String>.parseId3GenreName() = flatMap { it.parseId3GenreName() }
private fun String.parseId3v1Genre(): String? =
when {

View file

@ -169,7 +169,7 @@ class Task(context: Context, private val raw: Song.Raw) {
}
private fun completeAudio(metadata: Metadata) {
val id3v2Tags = mutableMapOf<String, String>()
val id3v2Tags = mutableMapOf<String, List<String>>()
val vorbisTags = mutableMapOf<String, MutableList<String>>()
// ExoPlayer only exposes ID3v2 and Vorbis metadata, which constitutes the vast majority
@ -179,9 +179,9 @@ class Task(context: Context, private val raw: Song.Raw) {
when (val tag = metadata[i]) {
is TextInformationFrame -> {
val id = tag.description?.let { "TXXX:${it.sanitize()}" } ?: tag.id.sanitize()
val value = tag.value.sanitize()
if (value.isNotEmpty()) {
id3v2Tags[id] = value
val values = tag.values.map { it.sanitize() }
if (values.isNotEmpty() && values.all { it.isNotEmpty() }) {
id3v2Tags[id] = values
}
}
is VorbisComment -> {
@ -207,16 +207,16 @@ class Task(context: Context, private val raw: Song.Raw) {
}
}
private fun populateId3v2(tags: Map<String, String>) {
private fun populateId3v2(tags: Map<String, List<String>>) {
// (Sort) Title
tags["TIT2"]?.let { raw.name = it }
tags["TSOT"]?.let { raw.sortName = it }
tags["TIT2"]?.let { raw.name = it[0] }
tags["TSOT"]?.let { raw.sortName = it[0] }
// Track, as NN/TT
tags["TRCK"]?.parsePositionNum()?.let { raw.track = it }
tags["TRCK"]?.run { get(0).parsePositionNum() }?.let { raw.track = it }
// Disc, as NN/TT
tags["TPOS"]?.parsePositionNum()?.let { raw.disc = it }
tags["TPOS"]?.run { get(0).parsePositionNum() } ?.let { raw.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
@ -227,22 +227,23 @@ class Task(context: Context, private val raw: Song.Raw) {
// 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"]?.parseTimestamp()
?: tags["TDRC"]?.parseTimestamp() ?: tags["TDRL"]?.parseTimestamp()
(tags["TDOR"]?.run { get(0).parseTimestamp() }
?: tags["TDRC"]?.run { get(0).parseTimestamp() }
?: tags["TDRL"]?.run { get(0).parseTimestamp() }
?: parseId3v23Date(tags))
?.let { raw.date = it }
// (Sort) Album
tags["TALB"]?.let { raw.albumName = it }
tags["TSOA"]?.let { raw.albumSortName = it }
tags["TALB"]?.let { raw.albumName = it[0] }
tags["TSOA"]?.let { raw.albumSortName = it[0] }
// (Sort) Artist
tags["TPE1"]?.let { raw.artistName = it }
tags["TSOP"]?.let { raw.artistSortName = it }
tags["TPE1"]?.let { raw.artistName = it.joinToString() }
tags["TSOP"]?.let { raw.artistSortName = it.joinToString() }
// (Sort) Album artist
tags["TPE2"]?.let { raw.albumArtistName = it }
tags["TSO2"]?.let { raw.albumArtistSortName = it }
tags["TPE2"]?.let { raw.albumArtistName = it.joinToString() }
tags["TSO2"]?.let { raw.albumArtistSortName = it.joinToString() }
// Genre, with the weird ID3 rules.
tags["TCON"]?.let { raw.genreNames = it.parseId3GenreName() }
@ -253,18 +254,18 @@ class Task(context: Context, private val raw: Song.Raw) {
}
}
private fun parseId3v23Date(tags: Map<String, String>): Date? {
val year = tags["TORY"]?.toIntOrNull() ?: tags["TYER"]?.toIntOrNull() ?: return null
private fun parseId3v23Date(tags: Map<String, List<String>>): Date? {
val year = tags["TORY"]?.run { get(0).toIntOrNull() } ?: tags["TYER"]?.run { get(0).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 tdat = tags["TDAT"]
return if (tdat != null && tdat[0].length == 4 && tdat[0].isDigitsOnly()) {
val mm = tdat[0].substring(0..1).toInt()
val dd = tdat[0].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()
val time = tags["TIME"]
if (time != null && time[0].length == 4 && time[0].isDigitsOnly()) {
val hh = time[0].substring(0..1).toInt()
val mi = time[0].substring(2..3).toInt()
Date.from(year, mm, dd, hh, mi)
} else {
Date.from(year, mm, dd)
@ -297,8 +298,8 @@ class Task(context: Context, private val raw: Song.Raw) {
?.let { raw.date = it }
// (Sort) Album
tags["ALBUM"]?.let { raw.albumName = it.joinToString() }
tags["ALBUMSORT"]?.let { raw.albumSortName = it.joinToString() }
tags["ALBUM"]?.let { raw.albumName = it[0] }
tags["ALBUMSORT"]?.let { raw.albumSortName = it[0] }
// (Sort) Artist
tags["ARTIST"]?.let { raw.artistName = it.joinToString() }

View file

@ -19,7 +19,7 @@ import re
# WARNING: THE EXOPLAYER VERSION MUST BE KEPT IN LOCK-STEP WITH THE FLAC EXTENSION AND
# THE GRADLE DEPENDENCY. IF NOT, VERY UNFRIENDLY BUILD FAILURES AND CRASHES MAY ENSUE.
EXO_VERSION = "2.18.1"
# EXO_VERSION = "2.18.1"
FLAC_VERSION = "1.3.2"
FATAL="\033[1;31m"
@ -95,9 +95,9 @@ sh("rm -rf " + exoplayer_path)
sh("rm -rf " + libs_path)
print(INFO + "info:" + NC + " cloning exoplayer...")
sh("git clone https://github.com/google/ExoPlayer.git " + exoplayer_path)
sh("git clone https://github.com/OxygenCobalt/ExoPlayer.git " + exoplayer_path)
os.chdir(exoplayer_path)
sh("git checkout r" + EXO_VERSION)
sh("git checkout auxio")
print(INFO + "info:" + NC + " assembling flac extension...")
flac_ext_aar_path = os.path.join(exoplayer_path, "extensions", "flac",
@ -111,9 +111,17 @@ sh(ndk_build_path + " APP_ABI=all -j4")
os.chdir(exoplayer_path)
sh("./gradlew extension-flac:bundleReleaseAar")
print(INFO + "info:" + NC + " assembling extractor component...")
extractor_aar_path = os.path.join(exoplayer_path, "library", "extractor",
"buildout", "outputs", "aar", "library-extractor-release.aar")
sh("./gradlew library-extractor:bundleReleaseAar")
os.chdir(start_path)
sh("mkdir " + libs_path)
sh("cp " + flac_ext_aar_path + " " + libs_path)
sh("cp " + extractor_aar_path + " " + libs_path)
print(OK + "success:" + NC + " completed pre-build")