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:
parent
2690e8343a
commit
2033e2cb1f
5 changed files with 68 additions and 103 deletions
|
@ -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
|
||||
|
|
|
@ -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()
|
||||
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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() }
|
||||
|
|
16
prebuild.py
16
prebuild.py
|
@ -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")
|
||||
|
|
Loading…
Reference in a new issue