music: refactor separator parsing
Add separator parsing back, albeit in a much different manner. Now Auxio will try to parse by , ; / + and &. This will be disabled by default in the future and available as a setting. When parsing by separator, whitespace is also trimmed. This occurs nowhere else though, as there is no demand.
This commit is contained in:
parent
2033e2cb1f
commit
81ca021ce7
9 changed files with 386 additions and 354 deletions
|
@ -54,7 +54,14 @@ sealed class Music : Item {
|
|||
* fast-scrolling.
|
||||
*/
|
||||
val sortName: String?
|
||||
get() = rawSortName ?: rawName?.parseSortName()
|
||||
get() = rawSortName ?: rawName?.run {
|
||||
when {
|
||||
length > 5 && startsWith("the ", ignoreCase = true) -> substring(4)
|
||||
length > 4 && startsWith("an ", ignoreCase = true) -> substring(3)
|
||||
length > 3 && startsWith("a ", ignoreCase = true) -> substring(2)
|
||||
else -> this
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a name from it's raw form to a form suitable to be shown in a ui. Ex. "unknown" would
|
||||
|
@ -79,7 +86,7 @@ sealed class Music : Item {
|
|||
* external sources, as it can persist across app restarts and does not need to encode useless
|
||||
* information about the relationships between items.
|
||||
*
|
||||
* TODO: MusizBrainz tags
|
||||
* TODO: MusicBrainz tags
|
||||
*
|
||||
* @author OxygenCobalt
|
||||
*/
|
||||
|
@ -186,19 +193,24 @@ class Song constructor(private val raw: Raw) : Music() {
|
|||
// TODO: Multi-artist support
|
||||
// private val _artists: MutableList<Artist> = mutableListOf()
|
||||
|
||||
private val artistName = raw.artistNames?.joinToString()
|
||||
private val albumArtistName = raw.albumArtistNames?.joinToString()
|
||||
private val artistSortName = raw.artistSortNames?.joinToString()
|
||||
private val albumArtistSortName = raw.albumArtistSortNames?.joinToString()
|
||||
|
||||
/**
|
||||
* 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 individualArtistRawName: String?
|
||||
get() = raw.artistName ?: album.artist.rawName
|
||||
get() = artistName ?: album.artist.rawName
|
||||
|
||||
/**
|
||||
* Resolve the 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)
|
||||
*/
|
||||
fun resolveIndividualArtistName(context: Context) =
|
||||
raw.artistName ?: album.artist.resolveName(context)
|
||||
artistName ?: album.artist.resolveName(context)
|
||||
|
||||
private val _genres: MutableList<Genre> = mutableListOf()
|
||||
/**
|
||||
|
@ -218,10 +230,10 @@ class Song constructor(private val raw: Raw) : Music() {
|
|||
date = raw.date,
|
||||
releaseType = raw.albumReleaseType,
|
||||
rawArtist =
|
||||
if (raw.albumArtistName != null) {
|
||||
Artist.Raw(raw.albumArtistName, raw.albumArtistSortName)
|
||||
if (albumArtistName != null) {
|
||||
Artist.Raw(albumArtistName, albumArtistSortName)
|
||||
} else {
|
||||
Artist.Raw(raw.artistName, raw.artistSortName)
|
||||
Artist.Raw(artistName, artistSortName)
|
||||
})
|
||||
|
||||
val _rawGenres = raw.genreNames?.map { Genre.Raw(it) } ?: listOf(Genre.Raw(null))
|
||||
|
@ -248,8 +260,8 @@ class Song constructor(private val raw: Raw) : Music() {
|
|||
update(_rawAlbum.name.lowercase())
|
||||
update(_rawAlbum.date)
|
||||
|
||||
update(raw.artistName)
|
||||
update(raw.albumArtistName)
|
||||
update(artistName)
|
||||
update(albumArtistName)
|
||||
|
||||
update(track)
|
||||
update(disc)
|
||||
|
@ -275,10 +287,10 @@ class Song constructor(private val raw: Raw) : Music() {
|
|||
var albumName: String? = null,
|
||||
var albumSortName: String? = null,
|
||||
var albumReleaseType: ReleaseType? = null,
|
||||
var artistName: String? = null,
|
||||
var artistSortName: String? = null,
|
||||
var albumArtistName: String? = null,
|
||||
var albumArtistSortName: String? = null,
|
||||
var artistNames: List<String>? = null,
|
||||
var artistSortNames: List<String>? = null,
|
||||
var albumArtistNames: List<String>? = null,
|
||||
var albumArtistSortNames: List<String>? = null,
|
||||
var genreNames: List<String>? = null
|
||||
)
|
||||
}
|
||||
|
@ -729,7 +741,7 @@ sealed class ReleaseType {
|
|||
|
||||
companion object {
|
||||
fun parse(types: List<String>): ReleaseType {
|
||||
val primary = types[0].trim()
|
||||
val primary = types[0]
|
||||
|
||||
// Primary types should be the first one in sequence. The spec makes no mention of
|
||||
// whether primary types are a pre-requisite for secondary types, so we assume that
|
||||
|
@ -747,7 +759,7 @@ sealed class ReleaseType {
|
|||
secondaryIdx: Int,
|
||||
target: (Refinement?) -> ReleaseType
|
||||
): ReleaseType {
|
||||
val secondary = (getOrNull(secondaryIdx) ?: return target(null)).trim()
|
||||
val secondary = (getOrNull(secondaryIdx) ?: return target(null))
|
||||
|
||||
return when {
|
||||
// Compilation is the only weird secondary release type, as it could
|
||||
|
|
|
@ -28,6 +28,13 @@ import org.oxycblt.auxio.R
|
|||
import org.oxycblt.auxio.util.nonZeroOrNull
|
||||
import java.util.UUID
|
||||
|
||||
/** Shortcut to resolve a year from a nullable date. Will return "No Date" if it is null. */
|
||||
fun Date?.resolveYear(context: Context) =
|
||||
this?.resolveYear(context) ?: context.getString(R.string.def_date)
|
||||
|
||||
/** Converts this string to a UUID, or returns null if it is not valid. */
|
||||
fun String.toUuid() = try { UUID.fromString(this) } catch (e: IllegalArgumentException) { null }
|
||||
|
||||
/** Shortcut for making a [ContentResolver] query with less superfluous arguments. */
|
||||
fun ContentResolver.queryCursor(
|
||||
uri: Uri,
|
||||
|
@ -58,320 +65,3 @@ val Long.audioUri: Uri
|
|||
/** Converts a [Long] Album ID into a URI pointing to MediaStore-cached album art. */
|
||||
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
|
||||
* invalid.
|
||||
*/
|
||||
fun Int.unpackTrackNo() = mod(1000).nonZeroOrNull()
|
||||
|
||||
/**
|
||||
* Parse out the disc 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 invalid.
|
||||
*/
|
||||
fun Int.unpackDiscNo() = div(1000).nonZeroOrNull()
|
||||
|
||||
/**
|
||||
* Parse out the number field from an NN/TT string that is typically found in DISC_NUMBER and
|
||||
* CD_TRACK_NUMBER. Values of zero will be ignored under the assumption that they are invalid.
|
||||
*/
|
||||
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::from)
|
||||
|
||||
/** Parse an ISO-8601 time-stamp from this field into a [Date]. */
|
||||
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) =
|
||||
this?.resolveYear(context) ?: context.getString(R.string.def_date)
|
||||
|
||||
/**
|
||||
* Slice a string so that any preceding articles like The/A(n) are truncated. This is hilariously
|
||||
* anglo-centric, but it's also a bit of an expected feature in music players, so we implement it
|
||||
* anyway.
|
||||
*/
|
||||
fun String.parseSortName() =
|
||||
when {
|
||||
length > 5 && startsWith("the ", ignoreCase = true) -> substring(4)
|
||||
length > 4 && startsWith("an ", ignoreCase = true) -> substring(3)
|
||||
length > 3 && startsWith("a ", ignoreCase = true) -> substring(2)
|
||||
else -> this
|
||||
}
|
||||
|
||||
/** Shortcut to parse a [ReleaseType] from a list of strings */
|
||||
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)
|
||||
|
||||
/**
|
||||
* 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 {
|
||||
// ID3v1 genres are a plain integer value without formatting, so in that case
|
||||
// try to index the genre table with such.
|
||||
isDigitsOnly() -> GENRE_TABLE.getOrNull(toInt())
|
||||
|
||||
// CR and RX are not technically ID3v1, but are formatted similarly to a plain number.
|
||||
this == "CR" -> "Cover"
|
||||
this == "RX" -> "Remix"
|
||||
|
||||
// Current name is fine.
|
||||
else -> null
|
||||
}
|
||||
|
||||
private fun String.parseId3v2Genre(): List<String>? {
|
||||
val groups = (GENRE_RE.matchEntire(this) ?: return null).groupValues
|
||||
val genres = mutableSetOf<String>()
|
||||
|
||||
// ID3v2 genres are far more complex and require string grokking to properly implement.
|
||||
// You can read the spec for it here: https://id3.org/id3v2.3.0#TCON
|
||||
// This implementation in particular is based off Mutagen's genre parser.
|
||||
|
||||
// Case 1: Genre IDs in the format (INT|RX|CR). If these exist, parse them as
|
||||
// ID3v1 tags.
|
||||
val genreIds = groups.getOrNull(1)
|
||||
if (genreIds != null && genreIds.isNotEmpty()) {
|
||||
val ids = genreIds.substring(1, genreIds.lastIndex).split(")(")
|
||||
for (id in ids) {
|
||||
id.parseId3v1Genre()?.let(genres::add)
|
||||
}
|
||||
}
|
||||
|
||||
// Case 2: Genre names as a normal string. The only case we have to look out for are
|
||||
// escaped strings formatted as ((genre).
|
||||
val genreName = groups.getOrNull(3)
|
||||
if (genreName != null && genreName.isNotEmpty()) {
|
||||
if (genreName.startsWith("((")) {
|
||||
genres.add(genreName.substring(1))
|
||||
} else {
|
||||
genres.add(genreName)
|
||||
}
|
||||
}
|
||||
|
||||
return genres.toList()
|
||||
}
|
||||
|
||||
/** Regex that implements matching for ID3v2's genre format. */
|
||||
private val GENRE_RE = Regex("((?:\\(([0-9]+|RX|CR)\\))*)(.+)?")
|
||||
|
||||
/**
|
||||
* A complete table of all the constant genre values for ID3(v2), including non-standard extensions.
|
||||
* Note that we do not translate these, as that greatly increases technical complexity.
|
||||
*/
|
||||
private val GENRE_TABLE =
|
||||
arrayOf(
|
||||
// ID3 Standard
|
||||
"Blues",
|
||||
"Classic Rock",
|
||||
"Country",
|
||||
"Dance",
|
||||
"Disco",
|
||||
"Funk",
|
||||
"Grunge",
|
||||
"Hip-Hop",
|
||||
"Jazz",
|
||||
"Metal",
|
||||
"New Age",
|
||||
"Oldies",
|
||||
"Other",
|
||||
"Pop",
|
||||
"R&B",
|
||||
"Rap",
|
||||
"Reggae",
|
||||
"Rock",
|
||||
"Techno",
|
||||
"Industrial",
|
||||
"Alternative",
|
||||
"Ska",
|
||||
"Death Metal",
|
||||
"Pranks",
|
||||
"Soundtrack",
|
||||
"Euro-Techno",
|
||||
"Ambient",
|
||||
"Trip-Hop",
|
||||
"Vocal",
|
||||
"Jazz+Funk",
|
||||
"Fusion",
|
||||
"Trance",
|
||||
"Classical",
|
||||
"Instrumental",
|
||||
"Acid",
|
||||
"House",
|
||||
"Game",
|
||||
"Sound Clip",
|
||||
"Gospel",
|
||||
"Noise",
|
||||
"AlternRock",
|
||||
"Bass",
|
||||
"Soul",
|
||||
"Punk",
|
||||
"Space",
|
||||
"Meditative",
|
||||
"Instrumental Pop",
|
||||
"Instrumental Rock",
|
||||
"Ethnic",
|
||||
"Gothic",
|
||||
"Darkwave",
|
||||
"Techno-Industrial",
|
||||
"Electronic",
|
||||
"Pop-Folk",
|
||||
"Eurodance",
|
||||
"Dream",
|
||||
"Southern Rock",
|
||||
"Comedy",
|
||||
"Cult",
|
||||
"Gangsta",
|
||||
"Top 40",
|
||||
"Christian Rap",
|
||||
"Pop/Funk",
|
||||
"Jungle",
|
||||
"Native American",
|
||||
"Cabaret",
|
||||
"New Wave",
|
||||
"Psychadelic",
|
||||
"Rave",
|
||||
"Showtunes",
|
||||
"Trailer",
|
||||
"Lo-Fi",
|
||||
"Tribal",
|
||||
"Acid Punk",
|
||||
"Acid Jazz",
|
||||
"Polka",
|
||||
"Retro",
|
||||
"Musical",
|
||||
"Rock & Roll",
|
||||
"Hard Rock",
|
||||
|
||||
// Winamp extensions, more or less a de-facto standard
|
||||
"Folk",
|
||||
"Folk-Rock",
|
||||
"National Folk",
|
||||
"Swing",
|
||||
"Fast Fusion",
|
||||
"Bebob",
|
||||
"Latin",
|
||||
"Revival",
|
||||
"Celtic",
|
||||
"Bluegrass",
|
||||
"Avantgarde",
|
||||
"Gothic Rock",
|
||||
"Progressive Rock",
|
||||
"Psychedelic Rock",
|
||||
"Symphonic Rock",
|
||||
"Slow Rock",
|
||||
"Big Band",
|
||||
"Chorus",
|
||||
"Easy Listening",
|
||||
"Acoustic",
|
||||
"Humour",
|
||||
"Speech",
|
||||
"Chanson",
|
||||
"Opera",
|
||||
"Chamber Music",
|
||||
"Sonata",
|
||||
"Symphony",
|
||||
"Booty Bass",
|
||||
"Primus",
|
||||
"Porn Groove",
|
||||
"Satire",
|
||||
"Slow Jam",
|
||||
"Club",
|
||||
"Tango",
|
||||
"Samba",
|
||||
"Folklore",
|
||||
"Ballad",
|
||||
"Power Ballad",
|
||||
"Rhythmic Soul",
|
||||
"Freestyle",
|
||||
"Duet",
|
||||
"Punk Rock",
|
||||
"Drum Solo",
|
||||
"A capella",
|
||||
"Euro-House",
|
||||
"Dance Hall",
|
||||
"Goa",
|
||||
"Drum & Bass",
|
||||
"Club-House",
|
||||
"Hardcore",
|
||||
"Terror",
|
||||
"Indie",
|
||||
"Britpop",
|
||||
"Negerpunk",
|
||||
"Polsk Punk",
|
||||
"Beat",
|
||||
"Christian Gangsta",
|
||||
"Heavy Metal",
|
||||
"Black Metal",
|
||||
"Crossover",
|
||||
"Contemporary Christian",
|
||||
"Christian Rock",
|
||||
"Merengue",
|
||||
"Salsa",
|
||||
"Thrash Metal",
|
||||
"Anime",
|
||||
"JPop",
|
||||
"Synthpop",
|
||||
|
||||
// Winamp 5.6+ extensions, also used by EasyTAG.
|
||||
// I only include this because post-rock is a based genre and deserves a slot.
|
||||
"Abstract",
|
||||
"Art Rock",
|
||||
"Baroque",
|
||||
"Bhangra",
|
||||
"Big Beat",
|
||||
"Breakbeat",
|
||||
"Chillout",
|
||||
"Downtempo",
|
||||
"Dub",
|
||||
"EBM",
|
||||
"Eclectic",
|
||||
"Electro",
|
||||
"Electroclash",
|
||||
"Emo",
|
||||
"Experimental",
|
||||
"Garage",
|
||||
"Global",
|
||||
"IDM",
|
||||
"Illbient",
|
||||
"Industro-Goth",
|
||||
"Jam Band",
|
||||
"Krautrock",
|
||||
"Leftfield",
|
||||
"Lounge",
|
||||
"Math Rock",
|
||||
"New Romantic",
|
||||
"Nu-Breakz",
|
||||
"Post-Punk",
|
||||
"Post-Rock",
|
||||
"Psytrance",
|
||||
"Shoegaze",
|
||||
"Space Rock",
|
||||
"Trop Rock",
|
||||
"World Music",
|
||||
"Neoclassical",
|
||||
"Audiobook",
|
||||
"Audio Theatre",
|
||||
"Neue Deutsche Welle",
|
||||
"Podcast",
|
||||
"Indie Rock",
|
||||
"G-Funk",
|
||||
"Dubstep",
|
||||
"Garage Rock",
|
||||
"Psybient",
|
||||
|
||||
// Auxio's extensions (Future garage is also based and deserves a slot)
|
||||
"Future Garage")
|
||||
|
|
|
@ -18,7 +18,11 @@
|
|||
package org.oxycblt.auxio.music
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.ContentResolver
|
||||
import android.content.ContentUris
|
||||
import android.content.Context
|
||||
import android.database.Cursor
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.Environment
|
||||
import android.os.storage.StorageManager
|
||||
|
@ -31,6 +35,7 @@ import java.lang.reflect.Method
|
|||
import org.oxycblt.auxio.R
|
||||
import org.oxycblt.auxio.util.lazyReflectedMethod
|
||||
|
||||
|
||||
/** A path to a file. [name] is the stripped file name, [parent] is the parent path. */
|
||||
data class Path(val name: String, val parent: Directory)
|
||||
|
||||
|
|
|
@ -28,11 +28,6 @@ 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
|
||||
import org.oxycblt.auxio.music.parsePositionNum
|
||||
import org.oxycblt.auxio.music.parseReleaseType
|
||||
import org.oxycblt.auxio.music.parseTimestamp
|
||||
import org.oxycblt.auxio.music.parseYear
|
||||
import org.oxycblt.auxio.util.logD
|
||||
import org.oxycblt.auxio.util.logW
|
||||
|
||||
|
@ -238,15 +233,15 @@ class Task(context: Context, private val raw: Song.Raw) {
|
|||
tags["TSOA"]?.let { raw.albumSortName = it[0] }
|
||||
|
||||
// (Sort) Artist
|
||||
tags["TPE1"]?.let { raw.artistName = it.joinToString() }
|
||||
tags["TSOP"]?.let { raw.artistSortName = it.joinToString() }
|
||||
tags["TPE1"]?.let { raw.artistNames = it.parseMultiValue() }
|
||||
tags["TSOP"]?.let { raw.artistSortNames = it.parseMultiValue() }
|
||||
|
||||
// (Sort) Album artist
|
||||
tags["TPE2"]?.let { raw.albumArtistName = it.joinToString() }
|
||||
tags["TSO2"]?.let { raw.albumArtistSortName = it.joinToString() }
|
||||
tags["TPE2"]?.let { raw.albumArtistNames = it.parseMultiValue() }
|
||||
tags["TSO2"]?.let { raw.albumArtistSortNames = it.parseMultiValue() }
|
||||
|
||||
// Genre, with the weird ID3 rules.
|
||||
tags["TCON"]?.let { raw.genreNames = it.parseId3GenreName() }
|
||||
tags["TCON"]?.let { raw.genreNames = it.parseId3GenreNames() }
|
||||
|
||||
// Release type (GRP1 is sometimes used for this, so fall back to it)
|
||||
(tags["TXXX:MusicBrainz Album Type"] ?: tags["GRP1"])?.parseReleaseType()?.let {
|
||||
|
@ -302,15 +297,15 @@ class Task(context: Context, private val raw: Song.Raw) {
|
|||
tags["ALBUMSORT"]?.let { raw.albumSortName = it[0] }
|
||||
|
||||
// (Sort) Artist
|
||||
tags["ARTIST"]?.let { raw.artistName = it.joinToString() }
|
||||
tags["ARTISTSORT"]?.let { raw.artistSortName = it.joinToString() }
|
||||
tags["ARTIST"]?.let { raw.artistNames = it.parseMultiValue() }
|
||||
tags["ARTISTSORT"]?.let { raw.artistSortNames = it.parseMultiValue() }
|
||||
|
||||
// (Sort) Album artist
|
||||
tags["ALBUMARTIST"]?.let { raw.albumArtistName = it.joinToString() }
|
||||
tags["ALBUMARTISTSORT"]?.let { raw.albumArtistSortName = it.joinToString() }
|
||||
tags["ALBUMARTIST"]?.let { raw.albumArtistNames = it.parseMultiValue() }
|
||||
tags["ALBUMARTISTSORT"]?.let { raw.albumArtistSortNames = it.parseMultiValue() }
|
||||
|
||||
// Genre, no ID3 rules here
|
||||
tags["GENRE"]?.let { raw.genreNames = it }
|
||||
tags["GENRE"]?.let { raw.genreNames = it.parseMultiValue() }
|
||||
|
||||
// Release type
|
||||
tags["RELEASETYPE"]?.parseReleaseType()?.let { raw.albumReleaseType = it }
|
||||
|
|
|
@ -189,7 +189,7 @@ abstract class MediaStoreBackend : Indexer.Backend {
|
|||
// format a genre was derived from, we have to treat them like they are ID3
|
||||
// genres, even when they might not be.
|
||||
val id = genreCursor.getLong(idIndex)
|
||||
val name = (genreCursor.getStringOrNull(nameIndex) ?: continue).parseId3GenreName()
|
||||
val name = (genreCursor.getStringOrNull(nameIndex) ?: continue).parseId3GenreNames()
|
||||
|
||||
context.contentResolverSafe.useQuery(
|
||||
MediaStore.Audio.Genres.Members.getContentUri(VOLUME_EXTERNAL, id),
|
||||
|
@ -293,13 +293,13 @@ abstract class MediaStoreBackend : Indexer.Backend {
|
|||
// as <unknown>, which makes absolutely no sense given how other fields default
|
||||
// to null if they are not present. If this field is <unknown>, null it so that
|
||||
// it's easier to handle later.
|
||||
raw.artistName =
|
||||
raw.artistNames =
|
||||
cursor.getString(artistIndex).run {
|
||||
if (this != MediaStore.UNKNOWN_STRING) this else null
|
||||
if (this != MediaStore.UNKNOWN_STRING) listOf(this) else null
|
||||
}
|
||||
|
||||
// The album artist field is nullable and never has placeholder values.
|
||||
raw.albumArtistName = cursor.getStringOrNull(albumArtistIndex)
|
||||
raw.albumArtistNames = cursor.getStringOrNull(albumArtistIndex)?.let { listOf(it) }
|
||||
|
||||
return raw
|
||||
}
|
||||
|
|
329
app/src/main/java/org/oxycblt/auxio/music/system/ParsingUtil.kt
Normal file
329
app/src/main/java/org/oxycblt/auxio/music/system/ParsingUtil.kt
Normal file
|
@ -0,0 +1,329 @@
|
|||
package org.oxycblt.auxio.music.system
|
||||
|
||||
import androidx.core.text.isDigitsOnly
|
||||
import org.oxycblt.auxio.music.Date
|
||||
import org.oxycblt.auxio.music.ReleaseType
|
||||
import org.oxycblt.auxio.util.nonZeroOrNull
|
||||
|
||||
/**
|
||||
* 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
|
||||
* invalid.
|
||||
*/
|
||||
fun Int.unpackTrackNo() = mod(1000).nonZeroOrNull()
|
||||
|
||||
/**
|
||||
* Parse out the disc 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 invalid.
|
||||
*/
|
||||
fun Int.unpackDiscNo() = div(1000).nonZeroOrNull()
|
||||
|
||||
/**
|
||||
* Parse out the number field from an NN/TT string that is typically found in DISC_NUMBER and
|
||||
* CD_TRACK_NUMBER. Values of zero will be ignored under the assumption that they are invalid.
|
||||
*/
|
||||
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::from)
|
||||
|
||||
/** Parse an ISO-8601 time-stamp from this field into a [Date]. */
|
||||
fun String.parseTimestamp() = Date.from(this)
|
||||
|
||||
private val SEPARATOR_REGEX = Regex("[^\\\\][\\[,;/+&]")
|
||||
private val ESCAPED_REGEX = Regex("\\\\[\\[,;/+&]")
|
||||
|
||||
fun List<String>.parseMultiValue() =
|
||||
if (size == 1) {
|
||||
get(0).parseSeparatorsImpl()
|
||||
} else {
|
||||
this
|
||||
}
|
||||
|
||||
private fun String.parseSeparatorsImpl() =
|
||||
// First split by non-escaped separators (No preceding \), and then split by escaped
|
||||
// separators.
|
||||
SEPARATOR_REGEX.split(this).map {
|
||||
ESCAPED_REGEX.replace(it) { match -> match.value.substring(1) }.trim()
|
||||
}
|
||||
|
||||
fun List<String>.parseReleaseType() =
|
||||
if (size == 1) {
|
||||
ReleaseType.parse(get(0).parseSeparatorsImpl())
|
||||
} else {
|
||||
ReleaseType.parse(this)
|
||||
}
|
||||
|
||||
fun List<String>.parseId3GenreNames() =
|
||||
if (size == 1) {
|
||||
get(0).parseId3GenreNames()
|
||||
} else {
|
||||
map { it.parseId3v1Genre() ?: it }
|
||||
}
|
||||
|
||||
fun String.parseId3GenreNames() =
|
||||
parseId3v1Genre()?.let { listOf(it) } ?:
|
||||
parseId3v2Genre() ?:
|
||||
parseSeparatorsImpl()
|
||||
|
||||
private fun String.parseId3v1Genre(): String? =
|
||||
when {
|
||||
// ID3v1 genres are a plain integer value without formatting, so in that case
|
||||
// try to index the genre table with such.
|
||||
isDigitsOnly() -> GENRE_TABLE.getOrNull(toInt())
|
||||
|
||||
// CR and RX are not technically ID3v1, but are formatted similarly to a plain number.
|
||||
this == "CR" -> "Cover"
|
||||
this == "RX" -> "Remix"
|
||||
|
||||
// Current name is fine.
|
||||
else -> null
|
||||
}
|
||||
|
||||
private fun String.parseId3v2Genre(): List<String>? {
|
||||
val groups = (GENRE_RE.matchEntire(this) ?: return null).groupValues
|
||||
val genres = mutableSetOf<String>()
|
||||
|
||||
// ID3v2 genres are far more complex and require string grokking to properly implement.
|
||||
// You can read the spec for it here: https://id3.org/id3v2.3.0#TCON
|
||||
// This implementation in particular is based off Mutagen's genre parser.
|
||||
|
||||
// Case 1: Genre IDs in the format (INT|RX|CR). If these exist, parse them as
|
||||
// ID3v1 tags.
|
||||
val genreIds = groups.getOrNull(1)
|
||||
if (genreIds != null && genreIds.isNotEmpty()) {
|
||||
val ids = genreIds.substring(1, genreIds.lastIndex).split(")(")
|
||||
for (id in ids) {
|
||||
id.parseId3v1Genre()?.let(genres::add)
|
||||
}
|
||||
}
|
||||
|
||||
// Case 2: Genre names as a normal string. The only case we have to look out for are
|
||||
// escaped strings formatted as ((genre).
|
||||
val genreName = groups.getOrNull(3)
|
||||
if (genreName != null && genreName.isNotEmpty()) {
|
||||
if (genreName.startsWith("((")) {
|
||||
genres.add(genreName.substring(1))
|
||||
} else {
|
||||
genres.add(genreName)
|
||||
}
|
||||
}
|
||||
|
||||
// If this parsing task didn't change anything, move on.
|
||||
if (genres.size == 1 && genres.first() == this) {
|
||||
return null
|
||||
}
|
||||
|
||||
return genres.toList()
|
||||
}
|
||||
|
||||
/** Regex that implements matching for ID3v2's genre format. */
|
||||
private val GENRE_RE = Regex("((?:\\((\\d+|RX|CR)\\))*)(.+)?")
|
||||
|
||||
/**
|
||||
* A complete table of all the constant genre values for ID3(v2), including non-standard extensions.
|
||||
* Note that we do not translate these, as that greatly increases technical complexity.
|
||||
*/
|
||||
private val GENRE_TABLE =
|
||||
arrayOf(
|
||||
// ID3 Standard
|
||||
"Blues",
|
||||
"Classic Rock",
|
||||
"Country",
|
||||
"Dance",
|
||||
"Disco",
|
||||
"Funk",
|
||||
"Grunge",
|
||||
"Hip-Hop",
|
||||
"Jazz",
|
||||
"Metal",
|
||||
"New Age",
|
||||
"Oldies",
|
||||
"Other",
|
||||
"Pop",
|
||||
"R&B",
|
||||
"Rap",
|
||||
"Reggae",
|
||||
"Rock",
|
||||
"Techno",
|
||||
"Industrial",
|
||||
"Alternative",
|
||||
"Ska",
|
||||
"Death Metal",
|
||||
"Pranks",
|
||||
"Soundtrack",
|
||||
"Euro-Techno",
|
||||
"Ambient",
|
||||
"Trip-Hop",
|
||||
"Vocal",
|
||||
"Jazz+Funk",
|
||||
"Fusion",
|
||||
"Trance",
|
||||
"Classical",
|
||||
"Instrumental",
|
||||
"Acid",
|
||||
"House",
|
||||
"Game",
|
||||
"Sound Clip",
|
||||
"Gospel",
|
||||
"Noise",
|
||||
"AlternRock",
|
||||
"Bass",
|
||||
"Soul",
|
||||
"Punk",
|
||||
"Space",
|
||||
"Meditative",
|
||||
"Instrumental Pop",
|
||||
"Instrumental Rock",
|
||||
"Ethnic",
|
||||
"Gothic",
|
||||
"Darkwave",
|
||||
"Techno-Industrial",
|
||||
"Electronic",
|
||||
"Pop-Folk",
|
||||
"Eurodance",
|
||||
"Dream",
|
||||
"Southern Rock",
|
||||
"Comedy",
|
||||
"Cult",
|
||||
"Gangsta",
|
||||
"Top 40",
|
||||
"Christian Rap",
|
||||
"Pop/Funk",
|
||||
"Jungle",
|
||||
"Native American",
|
||||
"Cabaret",
|
||||
"New Wave",
|
||||
"Psychadelic",
|
||||
"Rave",
|
||||
"Showtunes",
|
||||
"Trailer",
|
||||
"Lo-Fi",
|
||||
"Tribal",
|
||||
"Acid Punk",
|
||||
"Acid Jazz",
|
||||
"Polka",
|
||||
"Retro",
|
||||
"Musical",
|
||||
"Rock & Roll",
|
||||
"Hard Rock",
|
||||
|
||||
// Winamp extensions, more or less a de-facto standard
|
||||
"Folk",
|
||||
"Folk-Rock",
|
||||
"National Folk",
|
||||
"Swing",
|
||||
"Fast Fusion",
|
||||
"Bebob",
|
||||
"Latin",
|
||||
"Revival",
|
||||
"Celtic",
|
||||
"Bluegrass",
|
||||
"Avantgarde",
|
||||
"Gothic Rock",
|
||||
"Progressive Rock",
|
||||
"Psychedelic Rock",
|
||||
"Symphonic Rock",
|
||||
"Slow Rock",
|
||||
"Big Band",
|
||||
"Chorus",
|
||||
"Easy Listening",
|
||||
"Acoustic",
|
||||
"Humour",
|
||||
"Speech",
|
||||
"Chanson",
|
||||
"Opera",
|
||||
"Chamber Music",
|
||||
"Sonata",
|
||||
"Symphony",
|
||||
"Booty Bass",
|
||||
"Primus",
|
||||
"Porn Groove",
|
||||
"Satire",
|
||||
"Slow Jam",
|
||||
"Club",
|
||||
"Tango",
|
||||
"Samba",
|
||||
"Folklore",
|
||||
"Ballad",
|
||||
"Power Ballad",
|
||||
"Rhythmic Soul",
|
||||
"Freestyle",
|
||||
"Duet",
|
||||
"Punk Rock",
|
||||
"Drum Solo",
|
||||
"A capella",
|
||||
"Euro-House",
|
||||
"Dance Hall",
|
||||
"Goa",
|
||||
"Drum & Bass",
|
||||
"Club-House",
|
||||
"Hardcore",
|
||||
"Terror",
|
||||
"Indie",
|
||||
"Britpop",
|
||||
"Negerpunk",
|
||||
"Polsk Punk",
|
||||
"Beat",
|
||||
"Christian Gangsta",
|
||||
"Heavy Metal",
|
||||
"Black Metal",
|
||||
"Crossover",
|
||||
"Contemporary Christian",
|
||||
"Christian Rock",
|
||||
"Merengue",
|
||||
"Salsa",
|
||||
"Thrash Metal",
|
||||
"Anime",
|
||||
"JPop",
|
||||
"Synthpop",
|
||||
|
||||
// Winamp 5.6+ extensions, also used by EasyTAG.
|
||||
// I only include this because post-rock is a based genre and deserves a slot.
|
||||
"Abstract",
|
||||
"Art Rock",
|
||||
"Baroque",
|
||||
"Bhangra",
|
||||
"Big Beat",
|
||||
"Breakbeat",
|
||||
"Chillout",
|
||||
"Downtempo",
|
||||
"Dub",
|
||||
"EBM",
|
||||
"Eclectic",
|
||||
"Electro",
|
||||
"Electroclash",
|
||||
"Emo",
|
||||
"Experimental",
|
||||
"Garage",
|
||||
"Global",
|
||||
"IDM",
|
||||
"Illbient",
|
||||
"Industro-Goth",
|
||||
"Jam Band",
|
||||
"Krautrock",
|
||||
"Leftfield",
|
||||
"Lounge",
|
||||
"Math Rock",
|
||||
"New Romantic",
|
||||
"Nu-Breakz",
|
||||
"Post-Punk",
|
||||
"Post-Rock",
|
||||
"Psytrance",
|
||||
"Shoegaze",
|
||||
"Space Rock",
|
||||
"Trop Rock",
|
||||
"World Music",
|
||||
"Neoclassical",
|
||||
"Audiobook",
|
||||
"Audio Theatre",
|
||||
"Neue Deutsche Welle",
|
||||
"Podcast",
|
||||
"Indie Rock",
|
||||
"G-Funk",
|
||||
"Dubstep",
|
||||
"Garage Rock",
|
||||
"Psybient",
|
||||
|
||||
// Auxio's extensions (Future garage is also based and deserves a slot)
|
||||
"Future Garage")
|
|
@ -130,7 +130,7 @@ class ReplayGainAudioProcessor(context: Context) : BaseAudioProcessor() {
|
|||
// capitalization is consistent before continuing.
|
||||
is TextInformationFrame -> {
|
||||
key = entry.description?.uppercase()
|
||||
value = entry.value
|
||||
value = entry.values[0]
|
||||
}
|
||||
// Vorbis comment. These are nearly always uppercase, so a check for such is
|
||||
// skipped.
|
||||
|
|
|
@ -74,7 +74,6 @@ abstract class MenuFragment<T : ViewBinding> : ViewBindingFragment<T>() {
|
|||
}
|
||||
else -> {
|
||||
error("Unexpected menu item selected")
|
||||
return@musicMenuImpl false
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -142,7 +141,6 @@ abstract class MenuFragment<T : ViewBinding> : ViewBindingFragment<T>() {
|
|||
}
|
||||
else -> {
|
||||
error("Unexpected menu item selected")
|
||||
return@musicMenuImpl false
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -24,6 +24,7 @@ import java.lang.reflect.Method
|
|||
import java.util.concurrent.CancellationException
|
||||
import kotlin.reflect.KClass
|
||||
import org.oxycblt.auxio.BuildConfig
|
||||
import java.util.*
|
||||
|
||||
/** Assert that we are on a background thread. */
|
||||
fun requireBackgroundThread() {
|
||||
|
@ -43,8 +44,10 @@ fun <T> unlikelyToBeNull(value: T?) =
|
|||
value!!
|
||||
}
|
||||
|
||||
/** Returns null if this value is 0. */
|
||||
fun Int.nonZeroOrNull() = if (this > 0) this else null
|
||||
|
||||
/** Returns null if this value is not in [range]. */
|
||||
fun Int.inRangeOrNull(range: IntRange) = if (range.contains(this)) this else null
|
||||
|
||||
/** Converts a long in milliseconds to a long in deci-seconds */
|
||||
|
|
Loading…
Reference in a new issue