perf(smart-playlist): suggest rule values for what was typed
The rule editor's value box built its suggestions from libraryStore's whole-library arrays, which made it one more reason to load every track at startup, and left it empty when they had not loaded. SuggestSmartPlaylistValues answers with up to 50 distinct values of a field that contain the typed text, from the same joined row the rules match against, so a suggestion is always something a rule can match. yj-combobox announces its filter text so the editor can ask, debounced and cached per field and text. Refs #279
This commit is contained in:
1 parent
3f23bb4396
commit
3cddf70c4d
7 files changed
+356
-49
No files matched your search
@@ -614,7 +614,12 @@ const leanTrackQuery = `SELECT
|
||||
af.file_size,
|
||||
af.play_count,
|
||||
COALESCE(af.last_played, '') AS last_played
|
||||
FROM (
|
||||
FROM ` + leanTrackSource
|
||||
|
||||
// leanTrackSource is the joined row every rule's column resolves
|
||||
// against, aliased af. Shared by Evaluate and SuggestValues so a
|
||||
// suggestion is always a value a rule on the same field can match.
|
||||
const leanTrackSource = `(
|
||||
SELECT
|
||||
af.id,
|
||||
af.file_path,
|
||||
@@ -1117,3 +1122,79 @@ func splitGenres(concatenated string) []string {
|
||||
|
||||
return strings.Split(concatenated, genreDelimiter)
|
||||
}
|
||||
|
||||
// suggestLimit bounds one suggestion answer: a combobox shows a
|
||||
// screenful, and the user narrows by typing.
|
||||
const suggestLimit = 50
|
||||
|
||||
// errNoSuggestions is a field the editor does not offer values for.
|
||||
var errNoSuggestions = errors.New("field has no suggestions")
|
||||
|
||||
// suggestFields are the fields whose values the rule editor suggests.
|
||||
// Every one is in fieldMap; numeric fields other than the years are
|
||||
// ranges, where a list of every value present is no help.
|
||||
var suggestFields = map[string]bool{
|
||||
"title": true, "artist": true, "album": true, "genre": true,
|
||||
"composer": true, "file_type": true, "year": true, "release_year": true,
|
||||
}
|
||||
|
||||
// SuggestValues returns up to suggestLimit distinct values of field
|
||||
// present in the library that contain needle (case-insensitively),
|
||||
// sorted.
|
||||
//
|
||||
// The rule editor used to build these lists from libraryStore's
|
||||
// whole-library arrays, which made the editor one more reason to fetch
|
||||
// every track at startup (#279) — and gave it an empty list when the
|
||||
// arrays had not landed. Asking for the values that match what has
|
||||
// been typed is a few hundred bytes, whatever the library's size.
|
||||
func SuggestValues(db *database.DB, field, needle string) ([]string, error) {
|
||||
col, ok := fieldMap[field]
|
||||
if !ok || !suggestFields[field] {
|
||||
return nil, fmt.Errorf("%w: %q", errNoSuggestions, field)
|
||||
}
|
||||
|
||||
pattern := "%" + escapeLike(needle) + "%"
|
||||
|
||||
// SAFETY: col comes from fieldMap, never from the caller; the
|
||||
// needle is a bound parameter.
|
||||
query := `SELECT DISTINCT CAST(af.` + col + ` AS TEXT) AS v FROM ` +
|
||||
leanTrackSource + `
|
||||
WHERE v != '' AND v != '0' AND v LIKE ? ESCAPE '\'
|
||||
ORDER BY v COLLATE NOCASE LIMIT ?`
|
||||
|
||||
if field == "genre" {
|
||||
query = `SELECT name FROM genres
|
||||
WHERE name != '' AND name LIKE ? ESCAPE '\'
|
||||
ORDER BY name COLLATE NOCASE LIMIT ?`
|
||||
}
|
||||
|
||||
rows, err := db.QueryContext(query, pattern, suggestLimit)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("suggest %s: %w", field, err)
|
||||
}
|
||||
|
||||
defer func() { _ = rows.Close() }()
|
||||
|
||||
values := make([]string, 0, suggestLimit)
|
||||
|
||||
for rows.Next() {
|
||||
var v string
|
||||
if err := rows.Scan(&v); err != nil {
|
||||
return nil, fmt.Errorf("suggest %s: %w", field, err)
|
||||
}
|
||||
|
||||
values = append(values, v)
|
||||
}
|
||||
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("suggest %s: %w", field, err)
|
||||
}
|
||||
|
||||
return values, nil
|
||||
}
|
||||
|
||||
// escapeLike makes needle match itself literally inside a LIKE pattern
|
||||
// with ESCAPE '\'.
|
||||
func escapeLike(needle string) string {
|
||||
return strings.NewReplacer(`\`, `\\`, `%`, `\%`, `_`, `\_`).Replace(needle)
|
||||
}
|
||||
Reference in new issue
Block a user