search: add sort and file name to comparison

Make the search algorithm take in account the raw sort name and file
name when searching.

This allows the user to search for a particular song without typing in
a unicode/non-ideal title, instead typing in a latinized sort name or
suitable file name.

This does make Auxio's search a bit more fuzzy, but it still gets the
job done.

Resolves #184.
Related to #172.
This commit is contained in:
OxygenCobalt 2022-07-15 11:29:18 -06:00
parent d4f74784ba
commit 9f8ce49d70
No known key found for this signature in database
GPG key ID: 37DBE3621FE9AD47
3 changed files with 38 additions and 27 deletions

View file

@ -8,6 +8,7 @@ at the cost of longer loading times
- Added support for sort tags [#172, dependent on this feature] - Added support for sort tags [#172, dependent on this feature]
- Added support for date tags, including more fine-grained dates [#159, dependent on this feature] - Added support for date tags, including more fine-grained dates [#159, dependent on this feature]
- Added Last Added sorting - Added Last Added sorting
- Search now takes sort tags and file names in account [#184]
## 2.5.0 ## 2.5.0

View file

@ -292,18 +292,18 @@ data class Genre(override val rawName: String?, override val songs: List<Song>)
/** /**
* An ISO-8601/RFC 3339 Date. * An ISO-8601/RFC 3339 Date.
* *
* Unlike a typical Date within the standard library, this class is simply a 1:1 mapping between * Unlike a typical Date within the standard library, this class is simply a 1:1 mapping between the
* the tag date format of ID3v2 and (presumably) the Vorbis date format, implementing only format * tag date format of ID3v2 and (presumably) the Vorbis date format, implementing only format
* validation and excluding advanced or locale-specific date functionality.. * validation and excluding advanced or locale-specific date functionality..
* *
* The reasoning behind Date is that Auxio cannot trust any kind of metadata date to actually * The reasoning behind Date is that Auxio cannot trust any kind of metadata date to actually make
* make sense in a calendar, due to bad tagging, locale-specific issues, or simply from the * sense in a calendar, due to bad tagging, locale-specific issues, or simply from the limited
* limited nature of tag formats. Thus, it's better to use an analogous data structure that * nature of tag formats. Thus, it's better to use an analogous data structure that will not mangle
* will not mangle or reject valid-ish dates. * or reject valid-ish dates.
* *
* Date instances are immutable and their internal implementation is hidden. To instantiate one, * Date instances are immutable and their internal implementation is hidden. To instantiate one, use
* use [fromYear] or [parseTimestamp]. The string representation of a Date is RFC 3339, with * [fromYear] or [parseTimestamp]. The string representation of a Date is RFC 3339, with granular
* granular position depending on the presence of particular tokens. * position depending on the presence of particular tokens.
* *
* Please, *Do not use this for anything important related to time.* I cannot stress this enough. * Please, *Do not use this for anything important related to time.* I cannot stress this enough.
* This class will blow up if you try to do that. * This class will blow up if you try to do that.

View file

@ -29,12 +29,15 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.oxycblt.auxio.R import org.oxycblt.auxio.R
import org.oxycblt.auxio.music.Music import org.oxycblt.auxio.music.Music
import org.oxycblt.auxio.music.MusicParent
import org.oxycblt.auxio.music.MusicStore import org.oxycblt.auxio.music.MusicStore
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.settings.Settings import org.oxycblt.auxio.settings.Settings
import org.oxycblt.auxio.ui.DisplayMode import org.oxycblt.auxio.ui.DisplayMode
import org.oxycblt.auxio.ui.Sort import org.oxycblt.auxio.ui.Sort
import org.oxycblt.auxio.ui.recycler.Header import org.oxycblt.auxio.ui.recycler.Header
import org.oxycblt.auxio.ui.recycler.Item import org.oxycblt.auxio.ui.recycler.Item
import org.oxycblt.auxio.util.TaskGuard
import org.oxycblt.auxio.util.application import org.oxycblt.auxio.util.application
import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.util.logD
@ -56,11 +59,13 @@ class SearchViewModel(application: Application) :
get() = settings.searchFilterMode get() = settings.searchFilterMode
private var lastQuery: String? = null private var lastQuery: String? = null
private var guard = TaskGuard()
/** /**
* Use [query] to perform a search of the music library. Will push results to [searchResults]. * Use [query] to perform a search of the music library. Will push results to [searchResults].
*/ */
fun search(query: String?) { fun search(query: String?) {
val handle = guard.newHandle()
lastQuery = query lastQuery = query
val library = musicStore.library val library = musicStore.library
@ -80,33 +85,34 @@ class SearchViewModel(application: Application) :
// Note: a filter mode of null means to not filter at all. // Note: a filter mode of null means to not filter at all.
if (filterMode == null || filterMode == DisplayMode.SHOW_ARTISTS) { if (filterMode == null || filterMode == DisplayMode.SHOW_ARTISTS) {
library.artists.filterByOrNull(query)?.let { artists -> library.artists.filterParentsBy(query)?.let { artists ->
results.add(Header(-1, R.string.lbl_artists)) results.add(Header(-1, R.string.lbl_artists))
results.addAll(sort.artists(artists)) results.addAll(sort.artists(artists))
} }
} }
if (filterMode == null || filterMode == DisplayMode.SHOW_ALBUMS) { if (filterMode == null || filterMode == DisplayMode.SHOW_ALBUMS) {
library.albums.filterByOrNull(query)?.let { albums -> library.albums.filterParentsBy(query)?.let { albums ->
results.add(Header(-2, R.string.lbl_albums)) results.add(Header(-2, R.string.lbl_albums))
results.addAll(sort.albums(albums)) results.addAll(sort.albums(albums))
} }
} }
if (filterMode == null || filterMode == DisplayMode.SHOW_GENRES) { if (filterMode == null || filterMode == DisplayMode.SHOW_GENRES) {
library.genres.filterByOrNull(query)?.let { genres -> library.genres.filterParentsBy(query)?.let { genres ->
results.add(Header(-3, R.string.lbl_genres)) results.add(Header(-3, R.string.lbl_genres))
results.addAll(sort.genres(genres)) results.addAll(sort.genres(genres))
} }
} }
if (filterMode == null || filterMode == DisplayMode.SHOW_SONGS) { if (filterMode == null || filterMode == DisplayMode.SHOW_SONGS) {
library.songs.filterByOrNull(query)?.let { songs -> library.songs.filterSongsBy(query)?.let { songs ->
results.add(Header(-4, R.string.lbl_songs)) results.add(Header(-4, R.string.lbl_songs))
results.addAll(sort.songs(songs)) results.addAll(sort.songs(songs))
} }
} }
guard.yield(handle)
_searchResults.value = results _searchResults.value = results
} }
} }
@ -131,20 +137,24 @@ class SearchViewModel(application: Application) :
search(lastQuery) search(lastQuery)
} }
/** /** Searches the song list by the normalized name, then the sort name, and then the file name. */
* Shortcut that will run a ignoreCase filter on a list and only return a value if the resulting private fun List<Song>.filterSongsBy(value: String) =
* list is empty. baseFilterBy(value) { it.path.name.contains(value) }.ifEmpty { null }
*/
private fun <T : Music> List<T>.filterByOrNull(value: String): List<T>? {
val filtered = filter {
// Compare normalized names, which are names with unicode characters that are
// normalized to their non-unicode forms. This is just for quality-of-life,
// and I hope it doesn't bork search functionality for other languages.
it.resolveNameNormalized(application).contains(value, ignoreCase = true) ||
it.resolveNameNormalized(application).contains(value, ignoreCase = true)
}
return filtered.ifEmpty { null } /** Searches a list of parents by the normalized name, and then the sort name. */
private fun <T : MusicParent> List<T>.filterParentsBy(value: String) =
baseFilterBy(value) { false }.ifEmpty { null }
private inline fun <T : Music> List<T>.baseFilterBy(
value: String,
additional: (T) -> Boolean
) = filter {
// The basic comparison is first by the *normalized* name, as that allows a non-unicode
// search to match with some unicode characters. If that fails, we if there is a sort name
// we can leverage, as those are often used to make non-unicode variants of unicode titles.
it.resolveNameNormalized(application).contains(value, ignoreCase = true) ||
it.rawSortName?.contains(value, ignoreCase = true) == true ||
additional(it)
} }
private fun Music.resolveNameNormalized(context: Context): String { private fun Music.resolveNameNormalized(context: Context): String {