music: add basic framework for separators

Add a basic framework for user-customizeable separators.

This is designed to be extendable if needed, albeit the app will likely
only let the user choose between +, ;, /, &, and ,.
This commit is contained in:
Alexander Capehart 2022-09-08 18:16:06 -06:00
parent 574224ff99
commit 4c954e83b0
No known key found for this signature in database
GPG key ID: 37DBE3621FE9AD47
5 changed files with 76 additions and 33 deletions

View file

@ -28,6 +28,7 @@ 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.settings.Settings
import org.oxycblt.auxio.util.logD
import org.oxycblt.auxio.util.logW
@ -122,6 +123,7 @@ class ExoPlayerBackend(private val inner: MediaStoreBackend) : Indexer.Backend {
* @author OxygenCobalt
*/
class Task(context: Context, private val raw: Song.Raw) {
private val settings = Settings(context)
private val future =
MetadataRetriever.retrieveMetadata(
context,
@ -233,18 +235,18 @@ class Task(context: Context, private val raw: Song.Raw) {
tags["TSOA"]?.let { raw.albumSortName = it[0] }
// (Sort) Artist
tags["TPE1"]?.let { raw.artistNames = it.parseMultiValue() }
tags["TSOP"]?.let { raw.artistSortNames = it.parseMultiValue() }
tags["TPE1"]?.let { raw.artistNames = it.parseMultiValue(settings) }
tags["TSOP"]?.let { raw.artistSortNames = it.parseMultiValue(settings) }
// (Sort) Album artist
tags["TPE2"]?.let { raw.albumArtistNames = it.parseMultiValue() }
tags["TSO2"]?.let { raw.albumArtistSortNames = it.parseMultiValue() }
tags["TPE2"]?.let { raw.albumArtistNames = it.parseMultiValue(settings) }
tags["TSO2"]?.let { raw.albumArtistSortNames = it.parseMultiValue(settings) }
// Genre, with the weird ID3 rules.
tags["TCON"]?.let { raw.genreNames = it.parseId3GenreNames() }
tags["TCON"]?.let { raw.genreNames = it.parseId3GenreNames(settings) }
// Release type (GRP1 is sometimes used for this, so fall back to it)
(tags["TXXX:MusicBrainz Album Type"] ?: tags["GRP1"])?.parseReleaseType()?.let {
(tags["TXXX:MusicBrainz Album Type"] ?: tags["GRP1"])?.parseReleaseType(settings)?.let {
raw.albumReleaseType = it
}
}
@ -297,18 +299,18 @@ class Task(context: Context, private val raw: Song.Raw) {
tags["ALBUMSORT"]?.let { raw.albumSortName = it[0] }
// (Sort) Artist
tags["ARTIST"]?.let { raw.artistNames = it.parseMultiValue() }
tags["ARTISTSORT"]?.let { raw.artistSortNames = it.parseMultiValue() }
tags["ARTIST"]?.let { raw.artistNames = it.parseMultiValue(settings) }
tags["ARTISTSORT"]?.let { raw.artistSortNames = it.parseMultiValue(settings) }
// (Sort) Album artist
tags["ALBUMARTIST"]?.let { raw.albumArtistNames = it.parseMultiValue() }
tags["ALBUMARTISTSORT"]?.let { raw.albumArtistSortNames = it.parseMultiValue() }
tags["ALBUMARTIST"]?.let { raw.albumArtistNames = it.parseMultiValue(settings) }
tags["ALBUMARTISTSORT"]?.let { raw.albumArtistSortNames = it.parseMultiValue(settings) }
// Genre, no ID3 rules here
tags["GENRE"]?.let { raw.genreNames = it.parseMultiValue() }
tags["GENRE"]?.let { raw.genreNames = it.parseMultiValue(settings) }
// Release type
tags["RELEASETYPE"]?.parseReleaseType()?.let { raw.albumReleaseType = it }
tags["RELEASETYPE"]?.parseReleaseType(settings)?.let { raw.albumReleaseType = it }
}
/**

View file

@ -91,6 +91,9 @@ import org.oxycblt.auxio.util.logD
* I wish I was born in the neolithic.
*/
// TODO: Make context a member var to cache Settings
// TODO: Move duration util to MusicUtil
/**
* Represents a [Indexer.Backend] that loads music from the media database ([MediaStore]). This is
* not a fully-featured class by itself, and it's API-specific derivatives should be used instead.
@ -163,6 +166,8 @@ abstract class MediaStoreBackend : Indexer.Backend {
cursor: Cursor,
emitIndexing: (Indexer.Indexing) -> Unit
): List<Song> {
val settings = Settings(context)
val rawSongs = mutableListOf<Song.Raw>()
while (cursor.moveToNext()) {
rawSongs.add(buildRawSong(context, cursor))
@ -189,7 +194,8 @@ 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).parseId3GenreNames()
val name = (genreCursor.getStringOrNull(nameIndex) ?: continue)
.parseId3GenreNames(settings)
context.contentResolverSafe.useQuery(
MediaStore.Audio.Genres.Members.getContentUri(VOLUME_EXTERNAL, id),
@ -249,6 +255,8 @@ abstract class MediaStoreBackend : Indexer.Backend {
* outlined in [projection].
*/
open fun buildRawSong(context: Context, cursor: Cursor): Song.Raw {
val settings = Settings(context)
// Initialize our cursor indices if we haven't already.
if (idIndex == -1) {
idIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns._ID)
@ -292,14 +300,19 @@ abstract class MediaStoreBackend : Indexer.Backend {
// Android does not make a non-existent artist tag null, it instead fills it in
// 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.
// it's easier to handle later. While we can't natively parse multi-value tags,
// from MediaStore itself, we can still parse by user-defined separators.
raw.artistNames =
cursor.getString(artistIndex).run {
if (this != MediaStore.UNKNOWN_STRING) listOf(this) else null
if (this != MediaStore.UNKNOWN_STRING) {
maybeParseSeparators(settings)
} else {
null
}
}
// The album artist field is nullable and never has placeholder values.
raw.albumArtistNames = cursor.getStringOrNull(albumArtistIndex)?.let { listOf(it) }
raw.albumArtistNames = cursor.getStringOrNull(albumArtistIndex)?.maybeParseSeparators(settings)
return raw
}

View file

@ -3,6 +3,7 @@ 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.settings.Settings
import org.oxycblt.auxio.util.nonZeroOrNull
/**
@ -30,8 +31,8 @@ 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("\\\\[\\[,;/+&]")
private val SEPARATOR_REGEX_CACHE = mutableMapOf<String, Regex>()
private val ESCAPE_REGEX_CACHE = mutableMapOf<String, Regex>()
/**
* Fully parse a multi-value tag.
@ -41,36 +42,49 @@ private val ESCAPED_REGEX = Regex("\\\\[\\[,;/+&]")
*
* Alternatively, if there are several tags already, it will be returned without modification.
*/
fun List<String>.parseMultiValue() =
fun List<String>.parseMultiValue(settings: Settings) =
if (size == 1) {
get(0).parseSeparatorsImpl()
get(0).maybeParseSeparators(settings)
} else {
this
}
/**
* Parse a tag into multiple values using a series of generic separators. Will trim whitespace.
* Maybe a single tag into multi values with the user-preferred separators. If not enabled,
* the plain string will be returned.
*/
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 String.maybeParseSeparators(settings: Settings): List<String> {
// Get the separators the user desires. If null, we don't parse any.
val separators = settings.separators ?: return listOf(this)
// Try to cache compiled regexes for particular separator combinations.
val regex = synchronized(SEPARATOR_REGEX_CACHE) {
SEPARATOR_REGEX_CACHE.getOrPut(separators) { Regex("[^\\\\][$separators]") }
}
val escape = synchronized(ESCAPE_REGEX_CACHE) {
ESCAPE_REGEX_CACHE.getOrPut(separators) { Regex("\\\\[$separators]")}
}
return regex.split(this).map { value ->
// Convert escaped separators to their correct value
escape.replace(value) { match -> match.value.substring(1) }.trim()
}
}
/**
* Parse a multi-value tag into a [ReleaseType], handling separators in the process.
*/
fun List<String>.parseReleaseType() = ReleaseType.parse(parseMultiValue())
fun List<String>.parseReleaseType(settings: Settings) = ReleaseType.parse(parseMultiValue(settings))
/**
* Parse a multi-value genre name using ID3v2 rules. If there is one value, the ID3v2.3
* rules will be used, followed by separator parsing. Otherwise, each value will be iterated
* through, and numeric values transformed into string values.
*/
fun List<String>.parseId3GenreNames() =
fun List<String>.parseId3GenreNames(settings: Settings) =
if (size == 1) {
get(0).parseId3GenreNames()
get(0).parseId3GenreNames(settings)
} else {
map { it.parseId3v1Genre() ?: it }
}
@ -78,10 +92,10 @@ fun List<String>.parseId3GenreNames() =
/**
* Parse a single genre name using ID3v2.3 rules.
*/
fun String.parseId3GenreNames() =
fun String.parseId3GenreNames(settings: Settings) =
parseId3v1Genre()?.let { listOf(it) } ?:
parseId3v2Genre() ?:
parseSeparatorsImpl()
maybeParseSeparators(settings)
private fun String.parseId3v1Genre(): String? =
when {

View file

@ -219,13 +219,26 @@ class Settings(private val context: Context, private val callback: Callback? = n
}
}
/**
* The list of separators the user wants to parse by.
*/
var separators: String?
// Differ from convention and store a string of separator characters instead of an int
// code. This makes it easier to use in Regexes and makes it more extendable.
get() = inner.getString(context.getString(R.string.set_key_separators), null)?.ifEmpty { null }
set(value) {
inner.edit {
putString(context.getString(R.string.set_key_separators), value)
apply()
}
}
/** The current filter mode of the search tab */
var searchFilterMode: DisplayMode?
get() =
DisplayMode.fromInt(
inner.getInt(context.getString(R.string.set_key_search_filter), Int.MIN_VALUE))
set(value) {
logD(value)
inner.edit {
putInt(
context.getString(R.string.set_key_search_filter),

View file

@ -25,13 +25,14 @@
<string name="set_key_keep_shuffle" translatable="false">KEY_KEEP_SHUFFLE</string>
<string name="set_key_rewind_prev" translatable="false">KEY_PREV_REWIND</string>
<string name="set_key_repeat_pause" translatable="false">KEY_LOOP_PAUSE</string>
<string name="set_key_save_state" translatable="false">auxio_save_state</string>
<string name="set_key_wipe_state" translatable="false">auxio_wipe_state</string>
<string name="set_key_restore_state" translatable="false">auxio_restore_state</string>
<string name="set_key_reindex" translatable="false">auxio_reindex</string>
<string name="set_key_music_dirs" translatable="false">auxio_music_dirs</string>
<string name="set_key_music_dirs_include" translatable="false">auxio_include_dirs</string>
<string name="set_key_separators" translatable="false">auxio_separators</string>
<string name="set_key_observing" translatable="false">auxio_observing</string>
<string name="set_key_quality_tags" translatable="false">auxio_quality_tags</string>