Scoped library payloads: load the track list when a view needs it #287

Open
yonlu wants to merge 10 commits from perf/279-284-scoped-library-payloads into main
pull from: perf/279-284-scoped-library-payloads
7 changed files with 356 additions and 49 deletions
Showing only changes of commit 3cddf70c4d - Show all commits

No files matched your search

+13
View File
@@ -3125,6 +3125,19 @@ func (s *Service) EvaluateSmartPlaylist(
return tracks, nil return tracks, nil
} }
// SuggestSmartPlaylistValues returns values of field present in the
// library that contain needle, for the rule editor's value box.
func (s *Service) SuggestSmartPlaylistValues(
field, needle string,
) ([]string, error) {
values, err := smartplaylist.SuggestValues(s.db, field, needle)
if err != nil {
return nil, fmt.Errorf("suggest smart playlist values: %w", err)
}
return values, nil
}
// PreviewSmartPlaylist evaluates a rule set from raw JSON without // PreviewSmartPlaylist evaluates a rule set from raw JSON without
// requiring a saved playlist. This powers live preview in the rule // requiring a saved playlist. This powers live preview in the rule
// editor — the frontend sends rules as they are being edited and // editor — the frontend sends rules as they are being edited and
+82 -1
View File
@@ -614,7 +614,12 @@ const leanTrackQuery = `SELECT
af.file_size, af.file_size,
af.play_count, af.play_count,
COALESCE(af.last_played, '') AS last_played 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 SELECT
af.id, af.id,
af.file_path, af.file_path,
@@ -1117,3 +1122,79 @@ func splitGenres(concatenated string) []string {
return strings.Split(concatenated, genreDelimiter) 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)
}
+61
View File
@@ -0,0 +1,61 @@
package smartplaylist
import (
"errors"
"slices"
"testing"
"yellowjacket/backend/database"
)
// The rule editor's value box asks the backend for what matches the
// text typed (#279), rather than building every list from the whole
// library loaded into the frontend.
func TestSuggestValues(t *testing.T) {
t.Parallel()
db := database.NewTestDB(t)
seedSmartPlaylistData(t, db)
cases := []struct {
field, needle string
want []string
}{
// Case-insensitive substring, distinct, sorted.
{"artist", "q", []string{"QOTSA", "Queen"}},
{"album", "OPERA", []string{"A Night at the Opera"}},
// Genres come from the genre table, one value per genre, not
// from a concatenated per-track column.
{"genre", "rock", []string{
"Funk Rock", "Hard Rock", "Progressive Rock", "Rock", "Stoner Rock",
}},
// Years are numbers and are suggested as their text.
{"year", "199", []string{"1990", "1991"}},
// The needle is literal: a LIKE wildcard in it matches only itself.
{"title", "%", []string{}},
{"composer", "_", []string{}},
}
for _, tc := range cases {
got, err := SuggestValues(db, tc.field, tc.needle)
if err != nil {
t.Fatalf("SuggestValues(%q, %q): %v", tc.field, tc.needle, err)
}
if !slices.Equal(got, tc.want) {
t.Errorf("SuggestValues(%q, %q) = %q, want %q", tc.field, tc.needle, got, tc.want)
}
}
}
func TestSuggestValuesRefusesFieldsItDoesNotSuggest(t *testing.T) {
t.Parallel()
db := database.NewTestDB(t)
for _, field := range []string{"duration", "file_path", "title; DROP TABLE x"} {
if _, err := SuggestValues(db, field, ""); !errors.Is(err, errNoSuggestions) {
t.Errorf("SuggestValues(%q) err = %v, want errNoSuggestions", field, err)
}
}
}
@@ -309,6 +309,14 @@ export function SearchLibrary(query: string): $CancellablePromise<$models.Candid
return $Call.ByID(3912116995, query); return $Call.ByID(3912116995, query);
} }
/**
* SuggestSmartPlaylistValues returns values of field present in the
* library that contain needle, for the rule editor's value box.
*/
export function SuggestSmartPlaylistValues(field: string, needle: string): $CancellablePromise<string[] | null> {
return $Call.ByID(3718198115, field, needle);
}
/** /**
* ToggleDefaultPlaylistTrack adds or removes a single track * ToggleDefaultPlaylistTrack adds or removes a single track
* from the default playlist. Returns true if the track is now * from the default playlist. Returns true if the track is now
@@ -7,6 +7,11 @@ import { designTokens } from '../../styles/tokens.css';
* keyboard navigation. Accepts a flat `options` string array, filters as * keyboard navigation. Accepts a flat `options` string array, filters as
* the user types, and emits `combobox-change` when a value is selected. * the user types, and emits `combobox-change` when a value is selected.
* *
* It also emits `combobox-input` with `{ text }` whenever the text it
* filters by changes (typing, and the reset to empty on focus), so a
* host whose options are too many to hand over at once can fetch the
* ones matching what was typed instead (#279).
*
* Key implementation detail: option `<li>` elements use `@mousedown` with * Key implementation detail: option `<li>` elements use `@mousedown` with
* `e.preventDefault()` so that the input's `blur` event does not close the * `e.preventDefault()` so that the input's `blur` event does not close the
* dropdown before the click registers. * dropdown before the click registers.
@@ -196,6 +201,7 @@ export class YjCombobox extends LitElement {
this.filterText = input.value; this.filterText = input.value;
this.open = true; this.open = true;
this.highlightedIndex = -1; this.highlightedIndex = -1;
this.announceInput();
} }
private handleFocus() { private handleFocus() {
@@ -203,6 +209,17 @@ export class YjCombobox extends LitElement {
this.filterText = ''; this.filterText = '';
this.open = true; this.open = true;
this.highlightedIndex = -1; this.highlightedIndex = -1;
this.announceInput();
}
private announceInput() {
this.dispatchEvent(
new CustomEvent('combobox-input', {
detail: { text: this.filterText },
bubbles: true,
composed: true,
}),
);
} }
private handleBlur() { private handleBlur() {
@@ -1,8 +1,12 @@
import { LitElement, html, css, nothing } from 'lit'; import { LitElement, html, css, nothing } from 'lit';
import { customElement, property, state } from 'lit/decorators.js'; import { customElement, property, state } from 'lit/decorators.js';
import * as library from '@go/library/models.js'; import * as library from '@go/library/models.js';
import { PreviewSmartPlaylist } from '@go/playlist/service.js'; import {
import { libraryStore } from '@store/library-store'; PreviewSmartPlaylist,
SuggestSmartPlaylistValues,
} from '@go/playlist/service.js';
import { list } from '@utils/binding';
import { LRUMap } from '@utils/lru-map';
import { describeError } from '@utils/describe-error'; import { describeError } from '@utils/describe-error';
import { designTokens } from '../../styles/tokens.css'; import { designTokens } from '../../styles/tokens.css';
import '@components/combobox/combobox.ts'; import '@components/combobox/combobox.ts';
@@ -91,51 +95,23 @@ function formatOperatorLabel(op: string): string {
return op.replace(/_/g, ' '); return op.replace(/_/g, ' ');
} }
/** Returns autocomplete suggestions for a given field from libraryStore. */ /**
function getAutocompleteOptions(field: string): string[] { * Fields whose value box suggests values present in the library. Must
switch (field) { * match `suggestFields` in backend/smartplaylist; any other field gets
case 'artist': * a plain box.
return libraryStore.getCachedArtists()?.map((a) => a.Name) ?? []; *
case 'genre': * Suggestions are asked for as the user types (#279): they used to be
return libraryStore.getCachedGenres()?.map((g) => g.Name) ?? []; * built from libraryStore's whole-library arrays, which made this
case 'album': * editor one more reason to load every track at startup, and gave it
return libraryStore.getCachedAlbums()?.map((a) => a.Name) ?? []; * empty lists whenever those arrays had not landed.
case 'title': { */
const tracks = libraryStore.getCachedTracks(); const SUGGEST_FIELDS = new Set([
if (!tracks) return []; 'title', 'artist', 'album', 'genre', 'composer', 'file_type',
return [...new Set(tracks.map((t) => t.TrackName).filter(Boolean))]; 'year', 'release_year',
} ]);
case 'composer': {
const tracks = libraryStore.getCachedTracks(); /** How long typing must pause before suggestions are asked for. */
if (!tracks) return []; const SUGGEST_DEBOUNCE_MS = 120;
return [...new Set(tracks.map((t) => t.Composer).filter(Boolean))];
}
case 'file_type': {
const tracks = libraryStore.getCachedTracks();
if (!tracks) return [];
return [...new Set(tracks.map((t) => t.FileType).filter(Boolean))];
}
case 'year':
case 'release_year': {
// Both year fields draw suggestions from the set of years
// present in the library. The cached Track only carries the
// display (original) year, so it seeds both datalists — the
// list is just a hint, and the real filter runs server-side.
const tracks = libraryStore.getCachedTracks();
if (!tracks) return [];
return [
...new Set(
tracks
.map((t) => t.Year)
.filter((y) => y > 0)
.map(String),
),
].sort();
}
default:
return [];
}
}
/** /**
* Overrides for fields whose title-cased name would be ambiguous. The * Overrides for fields whose title-cased name would be ambiguous. The
@@ -206,6 +182,17 @@ export class SmartPlaylistEditor extends LitElement {
private previewTimer: ReturnType<typeof setTimeout> | null = null; private previewTimer: ReturnType<typeof setTimeout> | null = null;
/** The value suggestions each rule row is showing, by row index. */
@state() private suggestions = new Map<number, readonly string[]>();
private suggestTimer: ReturnType<typeof setTimeout> | null = null;
/** Answers already fetched this session, by field and typed text. */
private suggestCache = new LRUMap<string, readonly string[]>(100);
/** The latest request per row, so a slow answer cannot overwrite a newer one. */
private suggestWanted = new Map<number, string>();
// ── Styles ────────────────────────────────────────────────────── // ── Styles ──────────────────────────────────────────────────────
static override styles = [ static override styles = [
@@ -599,6 +586,7 @@ export class SmartPlaylistEditor extends LitElement {
// Reset value when field changes to avoid stale autocomplete data // Reset value when field changes to avoid stale autocomplete data
row.value = ''; row.value = '';
row.value2 = ''; row.value2 = '';
this.dropSuggestions();
this.ruleRows = [...this.ruleRows]; this.ruleRows = [...this.ruleRows];
this.onRulesChanged(); this.onRulesChanged();
@@ -628,6 +616,56 @@ export class SmartPlaylistEditor extends LitElement {
this.onRulesChanged(); this.onRulesChanged();
} }
/** Ask for the values of row `index`'s field that contain `text`. */
private requestSuggestions(index: number, text: string) {
const field = this.ruleRows[index]?.field ?? '';
if (!SUGGEST_FIELDS.has(field)) return;
const key = `${field}\u0000${text}`;
this.suggestWanted.set(index, key);
const cached = this.suggestCache.get(key);
if (cached) {
this.setSuggestions(index, cached);
return;
}
if (this.suggestTimer !== null) clearTimeout(this.suggestTimer);
this.suggestTimer = setTimeout(() => {
this.suggestTimer = null;
void list(SuggestSmartPlaylistValues(field, text))
.then((values) => {
this.suggestCache.set(key, values);
if (this.suggestWanted.get(index) === key) {
this.setSuggestions(index, values);
}
})
.catch((err: unknown) => {
// A suggestion is a hint; the box still takes any text.
console.error('smart playlist: suggestions failed', err);
});
}, SUGGEST_DEBOUNCE_MS);
}
private setSuggestions(index: number, values: readonly string[]) {
const next = new Map(this.suggestions);
next.set(index, values);
this.suggestions = next;
}
/** Row indexes and fields changed: what was suggested no longer applies. */
private dropSuggestions() {
this.suggestions = new Map();
this.suggestWanted.clear();
}
private updateValue2(index: number, newValue: string) { private updateValue2(index: number, newValue: string) {
const row = this.ruleRows[index]; const row = this.ruleRows[index];
if (!row) return; if (!row) return;
@@ -644,6 +682,7 @@ export class SmartPlaylistEditor extends LitElement {
private removeRule(index: number) { private removeRule(index: number) {
if (this.ruleRows.length <= 1) return; if (this.ruleRows.length <= 1) return;
this.ruleRows = this.ruleRows.filter((_, i) => i !== index); this.ruleRows = this.ruleRows.filter((_, i) => i !== index);
this.dropSuggestions();
this.onRulesChanged(); this.onRulesChanged();
} }
@@ -892,7 +931,9 @@ export class SmartPlaylistEditor extends LitElement {
` `
: html` : html`
<yj-combobox <yj-combobox
.options=${getAutocompleteOptions(row.field)} .options=${this.suggestions.get(index) ?? []}
@combobox-input=${(e: CustomEvent<{ text: string }>) =>
this.requestSuggestions(index, e.detail.text)}
.value=${row.value} .value=${row.value}
placeholder=${isAnyOf placeholder=${isAnyOf
? 'Comma-separated values' ? 'Comma-separated values'
@@ -0,0 +1,86 @@
/**
* The rule editor's value box suggests values from the library by
* asking for the ones that match what has been typed (#279).
*
* It used to build the lists from `libraryStore`'s whole-library
* arrays: one more reason to load every track at startup, and an empty
* list whenever they had not loaded. These tests stub *no* library
* collection — what is asserted is that the suggestions arrive anyway,
* from the one call made for them.
*/
import { describe, expect, it, beforeEach } from 'vitest';
import '@components/smart-playlist-editor/smart-playlist-editor';
import type { YjCombobox } from '@components/combobox/combobox';
import { calls, resetHarness, stub } from '@test/support/harness';
import { fixture, shadow, shadowAll } from '@test/support/render';
import type { LitElement } from 'lit';
const SUGGEST = 'playlist.Service.SuggestSmartPlaylistValues';
async function editor(field: string): Promise<LitElement> {
return fixture<LitElement>('smart-playlist-editor', {
rules: JSON.stringify({ rules: [{ field, operator: 'contains', value: '' }] }),
});
}
/** The row's second combobox: the value box (the first picks the field). */
function valueBox(el: LitElement): YjCombobox {
return shadowAll<YjCombobox>(el, 'yj-combobox')[1]!;
}
async function type(box: YjCombobox, text: string): Promise<void> {
const input = shadow<HTMLInputElement>(box, 'input')!;
input.focus();
input.value = text;
input.dispatchEvent(new Event('input', { bubbles: true }));
await box.updateComplete;
}
describe('smart playlist value suggestions', () => {
beforeEach(() => {
resetHarness();
stub(SUGGEST, (field: string, needle: string) =>
[`${field}:${needle}:1`, `${field}:${needle}:2`],
);
});
it('asks for the values matching what was typed, once typing pauses', async () => {
const el = await editor('artist');
const box = valueBox(el);
await type(box, 'q');
await type(box, 'qu');
await expect.poll(() => box.options).toEqual(['artist:qu:1', 'artist:qu:2']);
// Debounced: the pause after "qu" asked, the keystroke before did not.
expect(calls(SUGGEST).map((c) => c.args)).toEqual([['artist', 'qu']]);
});
it('answers a repeat from what it already fetched', async () => {
const el = await editor('album');
const box = valueBox(el);
await type(box, 'x');
await expect.poll(() => box.options).toEqual(['album:x:1', 'album:x:2']);
await type(box, 'xy');
await expect.poll(() => box.options).toEqual(['album:xy:1', 'album:xy:2']);
await type(box, 'x');
expect(box.options).toEqual(['album:x:1', 'album:x:2']);
expect(calls(SUGGEST)).toHaveLength(2);
});
it('asks nothing for a field that has no suggestions', async () => {
const el = await editor('duration');
const boxes = shadowAll<YjCombobox>(el, 'yj-combobox');
// A numeric range field may render no value combobox at all; if it
// does, typing in it must not ask.
if (boxes[1]) await type(boxes[1], '3');
await new Promise((r) => setTimeout(r, 250));
expect(calls(SUGGEST)).toHaveLength(0);
});
});