From 4c954e83b03aa0520df7c8be864a7f5c605f221d Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Thu, 8 Sep 2022 18:16:06 -0600 Subject: [PATCH] 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 ,. --- .../auxio/music/system/ExoPlayerBackend.kt | 26 ++++++----- .../auxio/music/system/MediaStoreBackend.kt | 21 +++++++-- .../oxycblt/auxio/music/system/ParsingUtil.kt | 44 ++++++++++++------- .../org/oxycblt/auxio/settings/Settings.kt | 15 ++++++- app/src/main/res/values/settings.xml | 3 +- 5 files changed, 76 insertions(+), 33 deletions(-) diff --git a/app/src/main/java/org/oxycblt/auxio/music/system/ExoPlayerBackend.kt b/app/src/main/java/org/oxycblt/auxio/music/system/ExoPlayerBackend.kt index 1fa905ac3..aae3e529c 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/system/ExoPlayerBackend.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/system/ExoPlayerBackend.kt @@ -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 } } /** diff --git a/app/src/main/java/org/oxycblt/auxio/music/system/MediaStoreBackend.kt b/app/src/main/java/org/oxycblt/auxio/music/system/MediaStoreBackend.kt index e77cc5e8e..32826f4b2 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/system/MediaStoreBackend.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/system/MediaStoreBackend.kt @@ -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 { + val settings = Settings(context) + val rawSongs = mutableListOf() 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 , which makes absolutely no sense given how other fields default // to null if they are not present. If this field is , 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 } diff --git a/app/src/main/java/org/oxycblt/auxio/music/system/ParsingUtil.kt b/app/src/main/java/org/oxycblt/auxio/music/system/ParsingUtil.kt index df5987b4d..36fc44d42 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/system/ParsingUtil.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/system/ParsingUtil.kt @@ -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() +private val ESCAPE_REGEX_CACHE = mutableMapOf() /** * 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.parseMultiValue() = +fun List.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 { + // 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.parseReleaseType() = ReleaseType.parse(parseMultiValue()) +fun List.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.parseId3GenreNames() = +fun List.parseId3GenreNames(settings: Settings) = if (size == 1) { - get(0).parseId3GenreNames() + get(0).parseId3GenreNames(settings) } else { map { it.parseId3v1Genre() ?: it } } @@ -78,10 +92,10 @@ fun List.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 { diff --git a/app/src/main/java/org/oxycblt/auxio/settings/Settings.kt b/app/src/main/java/org/oxycblt/auxio/settings/Settings.kt index 1e3d91949..1961957f7 100644 --- a/app/src/main/java/org/oxycblt/auxio/settings/Settings.kt +++ b/app/src/main/java/org/oxycblt/auxio/settings/Settings.kt @@ -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), diff --git a/app/src/main/res/values/settings.xml b/app/src/main/res/values/settings.xml index af188e1b3..69fa968a1 100644 --- a/app/src/main/res/values/settings.xml +++ b/app/src/main/res/values/settings.xml @@ -25,13 +25,14 @@ KEY_KEEP_SHUFFLE KEY_PREV_REWIND KEY_LOOP_PAUSE - auxio_save_state auxio_wipe_state auxio_restore_state + auxio_reindex auxio_music_dirs auxio_include_dirs + auxio_separators auxio_observing auxio_quality_tags