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.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.settings.Settings
import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.util.logD
import org.oxycblt.auxio.util.logW import org.oxycblt.auxio.util.logW
@ -122,6 +123,7 @@ class ExoPlayerBackend(private val inner: MediaStoreBackend) : Indexer.Backend {
* @author OxygenCobalt * @author OxygenCobalt
*/ */
class Task(context: Context, private val raw: Song.Raw) { class Task(context: Context, private val raw: Song.Raw) {
private val settings = Settings(context)
private val future = private val future =
MetadataRetriever.retrieveMetadata( MetadataRetriever.retrieveMetadata(
context, context,
@ -233,18 +235,18 @@ class Task(context: Context, private val raw: Song.Raw) {
tags["TSOA"]?.let { raw.albumSortName = it[0] } tags["TSOA"]?.let { raw.albumSortName = it[0] }
// (Sort) Artist // (Sort) Artist
tags["TPE1"]?.let { raw.artistNames = it.parseMultiValue() } tags["TPE1"]?.let { raw.artistNames = it.parseMultiValue(settings) }
tags["TSOP"]?.let { raw.artistSortNames = it.parseMultiValue() } tags["TSOP"]?.let { raw.artistSortNames = it.parseMultiValue(settings) }
// (Sort) Album artist // (Sort) Album artist
tags["TPE2"]?.let { raw.albumArtistNames = it.parseMultiValue() } tags["TPE2"]?.let { raw.albumArtistNames = it.parseMultiValue(settings) }
tags["TSO2"]?.let { raw.albumArtistSortNames = it.parseMultiValue() } tags["TSO2"]?.let { raw.albumArtistSortNames = it.parseMultiValue(settings) }
// Genre, with the weird ID3 rules. // 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) // 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 raw.albumReleaseType = it
} }
} }
@ -297,18 +299,18 @@ class Task(context: Context, private val raw: Song.Raw) {
tags["ALBUMSORT"]?.let { raw.albumSortName = it[0] } tags["ALBUMSORT"]?.let { raw.albumSortName = it[0] }
// (Sort) Artist // (Sort) Artist
tags["ARTIST"]?.let { raw.artistNames = it.parseMultiValue() } tags["ARTIST"]?.let { raw.artistNames = it.parseMultiValue(settings) }
tags["ARTISTSORT"]?.let { raw.artistSortNames = it.parseMultiValue() } tags["ARTISTSORT"]?.let { raw.artistSortNames = it.parseMultiValue(settings) }
// (Sort) Album artist // (Sort) Album artist
tags["ALBUMARTIST"]?.let { raw.albumArtistNames = it.parseMultiValue() } tags["ALBUMARTIST"]?.let { raw.albumArtistNames = it.parseMultiValue(settings) }
tags["ALBUMARTISTSORT"]?.let { raw.albumArtistSortNames = it.parseMultiValue() } tags["ALBUMARTISTSORT"]?.let { raw.albumArtistSortNames = it.parseMultiValue(settings) }
// Genre, no ID3 rules here // Genre, no ID3 rules here
tags["GENRE"]?.let { raw.genreNames = it.parseMultiValue() } tags["GENRE"]?.let { raw.genreNames = it.parseMultiValue(settings) }
// Release type // 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. * 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 * 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. * 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, cursor: Cursor,
emitIndexing: (Indexer.Indexing) -> Unit emitIndexing: (Indexer.Indexing) -> Unit
): List<Song> { ): List<Song> {
val settings = Settings(context)
val rawSongs = mutableListOf<Song.Raw>() val rawSongs = mutableListOf<Song.Raw>()
while (cursor.moveToNext()) { while (cursor.moveToNext()) {
rawSongs.add(buildRawSong(context, cursor)) 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 // format a genre was derived from, we have to treat them like they are ID3
// genres, even when they might not be. // genres, even when they might not be.
val id = genreCursor.getLong(idIndex) val id = genreCursor.getLong(idIndex)
val name = (genreCursor.getStringOrNull(nameIndex) ?: continue).parseId3GenreNames() val name = (genreCursor.getStringOrNull(nameIndex) ?: continue)
.parseId3GenreNames(settings)
context.contentResolverSafe.useQuery( context.contentResolverSafe.useQuery(
MediaStore.Audio.Genres.Members.getContentUri(VOLUME_EXTERNAL, id), MediaStore.Audio.Genres.Members.getContentUri(VOLUME_EXTERNAL, id),
@ -249,6 +255,8 @@ abstract class MediaStoreBackend : Indexer.Backend {
* outlined in [projection]. * outlined in [projection].
*/ */
open fun buildRawSong(context: Context, cursor: Cursor): Song.Raw { open fun buildRawSong(context: Context, cursor: Cursor): Song.Raw {
val settings = Settings(context)
// Initialize our cursor indices if we haven't already. // Initialize our cursor indices if we haven't already.
if (idIndex == -1) { if (idIndex == -1) {
idIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns._ID) 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 // 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 // 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 // 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 = raw.artistNames =
cursor.getString(artistIndex).run { 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. // 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 return raw
} }

View file

@ -3,6 +3,7 @@ package org.oxycblt.auxio.music.system
import androidx.core.text.isDigitsOnly import androidx.core.text.isDigitsOnly
import org.oxycblt.auxio.music.Date import org.oxycblt.auxio.music.Date
import org.oxycblt.auxio.music.ReleaseType import org.oxycblt.auxio.music.ReleaseType
import org.oxycblt.auxio.settings.Settings
import org.oxycblt.auxio.util.nonZeroOrNull 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]. */ /** Parse an ISO-8601 time-stamp from this field into a [Date]. */
fun String.parseTimestamp() = Date.from(this) fun String.parseTimestamp() = Date.from(this)
private val SEPARATOR_REGEX = Regex("[^\\\\][\\[,;/+&]") private val SEPARATOR_REGEX_CACHE = mutableMapOf<String, Regex>()
private val ESCAPED_REGEX = Regex("\\\\[\\[,;/+&]") private val ESCAPE_REGEX_CACHE = mutableMapOf<String, Regex>()
/** /**
* Fully parse a multi-value tag. * 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. * 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) { if (size == 1) {
get(0).parseSeparatorsImpl() get(0).maybeParseSeparators(settings)
} else { } else {
this 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() = fun String.maybeParseSeparators(settings: Settings): List<String> {
// First split by non-escaped separators (No preceding \), and then split by escaped // Get the separators the user desires. If null, we don't parse any.
// separators. val separators = settings.separators ?: return listOf(this)
SEPARATOR_REGEX.split(this).map {
ESCAPED_REGEX.replace(it) { match -> match.value.substring(1) }.trim() // 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. * 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 * 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 * rules will be used, followed by separator parsing. Otherwise, each value will be iterated
* through, and numeric values transformed into string values. * through, and numeric values transformed into string values.
*/ */
fun List<String>.parseId3GenreNames() = fun List<String>.parseId3GenreNames(settings: Settings) =
if (size == 1) { if (size == 1) {
get(0).parseId3GenreNames() get(0).parseId3GenreNames(settings)
} else { } else {
map { it.parseId3v1Genre() ?: it } map { it.parseId3v1Genre() ?: it }
} }
@ -78,10 +92,10 @@ fun List<String>.parseId3GenreNames() =
/** /**
* Parse a single genre name using ID3v2.3 rules. * Parse a single genre name using ID3v2.3 rules.
*/ */
fun String.parseId3GenreNames() = fun String.parseId3GenreNames(settings: Settings) =
parseId3v1Genre()?.let { listOf(it) } ?: parseId3v1Genre()?.let { listOf(it) } ?:
parseId3v2Genre() ?: parseId3v2Genre() ?:
parseSeparatorsImpl() maybeParseSeparators(settings)
private fun String.parseId3v1Genre(): String? = private fun String.parseId3v1Genre(): String? =
when { 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 */ /** The current filter mode of the search tab */
var searchFilterMode: DisplayMode? var searchFilterMode: DisplayMode?
get() = get() =
DisplayMode.fromInt( DisplayMode.fromInt(
inner.getInt(context.getString(R.string.set_key_search_filter), Int.MIN_VALUE)) inner.getInt(context.getString(R.string.set_key_search_filter), Int.MIN_VALUE))
set(value) { set(value) {
logD(value)
inner.edit { inner.edit {
putInt( putInt(
context.getString(R.string.set_key_search_filter), 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_keep_shuffle" translatable="false">KEY_KEEP_SHUFFLE</string>
<string name="set_key_rewind_prev" translatable="false">KEY_PREV_REWIND</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_repeat_pause" translatable="false">KEY_LOOP_PAUSE</string>
<string name="set_key_save_state" translatable="false">auxio_save_state</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_wipe_state" translatable="false">auxio_wipe_state</string>
<string name="set_key_restore_state" translatable="false">auxio_restore_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_reindex" translatable="false">auxio_reindex</string>
<string name="set_key_music_dirs" translatable="false">auxio_music_dirs</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_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_observing" translatable="false">auxio_observing</string>
<string name="set_key_quality_tags" translatable="false">auxio_quality_tags</string> <string name="set_key_quality_tags" translatable="false">auxio_quality_tags</string>