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
1201 lines
31 KiB
Go
1201 lines
31 KiB
Go
// Package smartplaylist builds parameterized SQL WHERE clauses from
|
|
// JSON rule definitions and evaluates them against the track_metadata
|
|
// view. Field names are whitelisted; values are always parameterized.
|
|
package smartplaylist
|
|
|
|
import (
|
|
"database/sql"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"yellowjacket/backend/coverart"
|
|
"yellowjacket/backend/database"
|
|
"yellowjacket/backend/library"
|
|
)
|
|
|
|
// Sentinel errors for rule validation.
|
|
var (
|
|
errInvalidField = errors.New("invalid field: not in allowed field list")
|
|
errInvalidOperator = errors.New("invalid operator for field type")
|
|
errEmptyIsAnyOf = errors.New("is_any_of requires at least one value")
|
|
errBetweenCount = errors.New("between requires exactly 2 values")
|
|
errBetweenFormat = errors.New("between value must be \"min,max\" or [\"min\",\"max\"]")
|
|
errUnsupportedOp = errors.New("unsupported operator")
|
|
errInvalidSortField = errors.New("invalid sort field: not in allowed field list")
|
|
errNotNumeric = errors.New("value must be numeric")
|
|
errInvalidMatch = errors.New("match must be \"all\" or \"any\"")
|
|
)
|
|
|
|
// Rule represents a single filter condition for a smart playlist.
|
|
type Rule struct {
|
|
Field string `json:"field"`
|
|
Operator string `json:"operator"`
|
|
Value string `json:"value"`
|
|
}
|
|
|
|
// MatchType decides how a rule set's conditions combine.
|
|
//
|
|
// The rules used to be joined with " AND " and nothing else, so a
|
|
// playlist could only ever narrow: "jazz released after 1960" was
|
|
// expressible and "jazz or blues" was not, which is most of what
|
|
// anyone reaches for a second rule to say.
|
|
type MatchType string
|
|
|
|
const (
|
|
// MatchAll requires every rule to hold — the historical behaviour,
|
|
// and what an empty match means so that every rule set written
|
|
// before this existed keeps the meaning it was saved with.
|
|
MatchAll MatchType = "all"
|
|
// MatchAny requires at least one rule to hold.
|
|
MatchAny MatchType = "any"
|
|
)
|
|
|
|
// joiner returns the SQL keyword that combines two conditions.
|
|
// An unrecognised value cannot reach here — ParseRuleSet rejects one
|
|
// — so the default is about the empty string, which is every rule set
|
|
// saved before this field existed.
|
|
func (m MatchType) joiner() string {
|
|
if m == MatchAny {
|
|
return " OR "
|
|
}
|
|
|
|
return " AND "
|
|
}
|
|
|
|
// RuleSet holds the complete filter configuration for a smart
|
|
// playlist, including optional sort and limit.
|
|
type RuleSet struct {
|
|
Rules []Rule `json:"rules"`
|
|
// Match is "all" or "any"; empty means "all". It is omitempty so
|
|
// an untouched playlist's stored JSON does not change shape.
|
|
Match MatchType `json:"match,omitempty"`
|
|
Limit int `json:"limit,omitempty"`
|
|
SortField string `json:"sort_field,omitempty"`
|
|
SortDir string `json:"sort_dir,omitempty"`
|
|
}
|
|
|
|
// fieldMap maps user-facing rule field names to track_metadata column
|
|
// names. Field names MUST come from this map — never interpolated
|
|
// from user input.
|
|
var fieldMap = map[string]string{
|
|
"title": "title",
|
|
"artist": "artist_name",
|
|
"album": "album",
|
|
"genre": "genre",
|
|
"year": "year",
|
|
"release_year": "release_year",
|
|
"composer": "composer",
|
|
"file_type": "file_type",
|
|
"duration": "length_milliseconds",
|
|
"sample_rate": "sample_rate",
|
|
"bit_depth": "bit_depth",
|
|
"channels": "channels",
|
|
"bitrate": "bitrate",
|
|
"file_size": "file_size",
|
|
"library": "library_id",
|
|
"track_number": "track_number",
|
|
"disc_number": "disc_number",
|
|
"play_count": "play_count",
|
|
"days_since_played": "days_since_played",
|
|
}
|
|
|
|
// numericFields identifies fields that accept numeric operators.
|
|
var numericFields = map[string]bool{
|
|
"year": true,
|
|
"release_year": true,
|
|
"duration": true,
|
|
"sample_rate": true,
|
|
"bit_depth": true,
|
|
"channels": true,
|
|
"bitrate": true,
|
|
"file_size": true,
|
|
"library": true,
|
|
"track_number": true,
|
|
"disc_number": true,
|
|
"play_count": true,
|
|
"days_since_played": true,
|
|
}
|
|
|
|
// textOperators are valid operators for text fields.
|
|
var textOperators = map[string]bool{
|
|
"is": true,
|
|
"is_not": true,
|
|
"contains": true,
|
|
"does_not_contain": true,
|
|
"starts_with": true,
|
|
"ends_with": true,
|
|
"is_any_of": true,
|
|
}
|
|
|
|
// numericOperators are valid operators for numeric fields.
|
|
var numericOperators = map[string]bool{
|
|
"is": true,
|
|
"is_not": true,
|
|
"greater_than": true,
|
|
"less_than": true,
|
|
"between": true,
|
|
}
|
|
|
|
// genreDelimiter matches the GROUP_CONCAT delimiter used when
|
|
// batch-loading genres for matched tracks.
|
|
const genreDelimiter = "||"
|
|
|
|
// BuildWhereClause builds a parameterized SQL WHERE clause from a
|
|
// slice of rules. It is a pure function — no database access needed.
|
|
// Returns the clause (without the leading "WHERE"), the parameter
|
|
// args, and any validation error.
|
|
//
|
|
// match decides how the conditions combine; an empty match is MatchAll,
|
|
// which is what every rule set saved before the field existed means.
|
|
func BuildWhereClause(
|
|
rules []Rule, match MatchType,
|
|
) (string, []any, error) {
|
|
if len(rules) == 0 {
|
|
return "", nil, nil
|
|
}
|
|
|
|
conditions := make([]string, 0, len(rules))
|
|
args := make([]any, 0, len(rules))
|
|
|
|
for _, rule := range rules {
|
|
col, ok := fieldMap[rule.Field]
|
|
if !ok {
|
|
return "", nil, fmt.Errorf(
|
|
"%w: %q", errInvalidField, rule.Field,
|
|
)
|
|
}
|
|
|
|
isNumeric := numericFields[rule.Field]
|
|
|
|
if err := validateOperator(rule.Operator, isNumeric); err != nil {
|
|
return "", nil, fmt.Errorf(
|
|
"field %q: %w", rule.Field, err,
|
|
)
|
|
}
|
|
|
|
// All genre operators use a subquery against recording_genres.
|
|
// The smart playlist main query does not project a genre
|
|
// column — genres are batch-loaded after the main query — so
|
|
// even text operators like "contains" must filter through the
|
|
// link table rather than a concatenated column.
|
|
if rule.Field == "genre" {
|
|
cond, condArgs, err := buildGenreCondition(rule)
|
|
if err != nil {
|
|
return "", nil, err
|
|
}
|
|
|
|
conditions = append(conditions, cond)
|
|
args = append(args, condArgs...)
|
|
|
|
continue
|
|
}
|
|
|
|
// days_since_played uses a computed expression, not a column.
|
|
if rule.Field == "days_since_played" {
|
|
cond, condArgs, err := buildDaysSincePlayedCondition(rule)
|
|
if err != nil {
|
|
return "", nil, err
|
|
}
|
|
|
|
conditions = append(conditions, cond)
|
|
args = append(args, condArgs...)
|
|
|
|
continue
|
|
}
|
|
|
|
cond, condArgs, err := buildCondition(col, rule, isNumeric)
|
|
if err != nil {
|
|
return "", nil, err
|
|
}
|
|
|
|
conditions = append(conditions, cond)
|
|
args = append(args, condArgs...)
|
|
}
|
|
|
|
// Under OR, each condition is parenthesised; under AND it is not.
|
|
//
|
|
// The asymmetry is deliberate rather than an omission. AND is the
|
|
// tighter operator in SQL, so an OR-join has to protect any
|
|
// condition that contains a top-level AND of its own or the halves
|
|
// come apart: `days_since_played less_than` is
|
|
// `last_played IS NOT NULL AND <expr> < ?`, which read without
|
|
// brackets under an OR-join happens to still parse correctly and
|
|
// would stop doing so the moment a condition grows a top-level OR.
|
|
// Bracketing under AND would be a no-op semantically and would
|
|
// rewrite the clause every existing test pins, so the brackets go
|
|
// exactly where they change something.
|
|
if match == MatchAny {
|
|
bracketed := make([]string, len(conditions))
|
|
for i, cond := range conditions {
|
|
bracketed[i] = "(" + cond + ")"
|
|
}
|
|
|
|
conditions = bracketed
|
|
}
|
|
|
|
return strings.Join(conditions, match.joiner()), args, nil
|
|
}
|
|
|
|
// validateOperator checks that the operator is valid for the field
|
|
// type.
|
|
func validateOperator(op string, isNumeric bool) error {
|
|
if isNumeric {
|
|
if !numericOperators[op] {
|
|
return fmt.Errorf(
|
|
"%w: %q for numeric field", errInvalidOperator, op,
|
|
)
|
|
}
|
|
} else {
|
|
if !textOperators[op] {
|
|
return fmt.Errorf(
|
|
"%w: %q for text field", errInvalidOperator, op,
|
|
)
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// buildGenreCondition generates a subquery condition against
|
|
// file_genres JOIN genres for every supported text operator. The outer
|
|
// query exposes the audio file's `id`, which is what file_genres is
|
|
// keyed by.
|
|
func buildGenreCondition(rule Rule) (string, []any, error) {
|
|
inHead := `af.id IN (
|
|
SELECT fg.audio_file_id FROM file_genres fg
|
|
JOIN genres g ON fg.genre_id = g.id
|
|
WHERE `
|
|
notInHead := `af.id NOT IN (
|
|
SELECT fg.audio_file_id FROM file_genres fg
|
|
JOIN genres g ON fg.genre_id = g.id
|
|
WHERE `
|
|
|
|
switch rule.Operator {
|
|
case "is":
|
|
return inHead + "g.name = ? COLLATE NOCASE)", []any{rule.Value}, nil
|
|
|
|
case "is_not":
|
|
return notInHead + "g.name = ? COLLATE NOCASE)", []any{rule.Value}, nil
|
|
|
|
case "contains":
|
|
return inHead + "g.name LIKE ?)",
|
|
[]any{"%" + rule.Value + "%"}, nil
|
|
|
|
case "does_not_contain":
|
|
return notInHead + "g.name LIKE ?)",
|
|
[]any{"%" + rule.Value + "%"}, nil
|
|
|
|
case "starts_with":
|
|
return inHead + "g.name LIKE ?)",
|
|
[]any{rule.Value + "%"}, nil
|
|
|
|
case "ends_with":
|
|
return inHead + "g.name LIKE ?)",
|
|
[]any{"%" + rule.Value}, nil
|
|
|
|
case "is_any_of":
|
|
var values []string
|
|
|
|
if err := json.Unmarshal(
|
|
[]byte(rule.Value), &values,
|
|
); err != nil {
|
|
return "", nil, fmt.Errorf(
|
|
"field %q: is_any_of value must be a JSON "+
|
|
"string array: %w",
|
|
rule.Field, err,
|
|
)
|
|
}
|
|
|
|
if len(values) == 0 {
|
|
return "", nil, fmt.Errorf(
|
|
"field %q: %w", rule.Field, errEmptyIsAnyOf,
|
|
)
|
|
}
|
|
|
|
placeholders := make([]string, len(values))
|
|
condArgs := make([]any, len(values))
|
|
|
|
for i, v := range values {
|
|
placeholders[i] = "? COLLATE NOCASE"
|
|
condArgs[i] = v
|
|
}
|
|
|
|
return inHead + "g.name IN (" +
|
|
strings.Join(placeholders, ", ") + "))", condArgs, nil
|
|
|
|
default:
|
|
return "", nil, fmt.Errorf(
|
|
"%w: %q", errUnsupportedOp, rule.Operator,
|
|
)
|
|
}
|
|
}
|
|
|
|
// buildDaysSincePlayedCondition generates a condition for the
|
|
// days_since_played computed field. Uses julianday() to compute
|
|
// the number of days between last_played and now. Tracks that
|
|
// have never been played (last_played IS NULL) are treated as
|
|
// having infinite days since played — they match "greater_than"
|
|
// any value but not "less_than".
|
|
func buildDaysSincePlayedCondition(rule Rule) (string, []any, error) {
|
|
// The expression: days since last played.
|
|
// NULL handling: COALESCE to a very old date so never-played
|
|
// tracks always have a large days_since_played value.
|
|
expr := "CAST(julianday('now') - julianday(COALESCE(last_played, '2000-01-01')) AS INTEGER)"
|
|
|
|
switch rule.Operator {
|
|
case "is":
|
|
v, err := parseNumericValue(rule.Field, rule.Operator, rule.Value)
|
|
if err != nil {
|
|
return "", nil, err
|
|
}
|
|
|
|
return expr + " = ?", []any{v}, nil
|
|
|
|
case "is_not":
|
|
v, err := parseNumericValue(rule.Field, rule.Operator, rule.Value)
|
|
if err != nil {
|
|
return "", nil, err
|
|
}
|
|
|
|
return expr + " != ?", []any{v}, nil
|
|
|
|
case "greater_than":
|
|
v, err := parseNumericValue(rule.Field, rule.Operator, rule.Value)
|
|
if err != nil {
|
|
return "", nil, err
|
|
}
|
|
|
|
return expr + " > ?", []any{v}, nil
|
|
|
|
case "less_than":
|
|
v, err := parseNumericValue(rule.Field, rule.Operator, rule.Value)
|
|
if err != nil {
|
|
return "", nil, err
|
|
}
|
|
|
|
// Never-played tracks (NULL last_played) should NOT match
|
|
// "less than N days" — they haven't been played recently.
|
|
return "last_played IS NOT NULL AND " + expr + " < ?", []any{v}, nil
|
|
|
|
case "between":
|
|
lo, hi, err := parseBetweenValue(rule.Field, rule.Value)
|
|
if err != nil {
|
|
return "", nil, err
|
|
}
|
|
|
|
return expr + " BETWEEN ? AND ?", []any{lo, hi}, nil
|
|
|
|
default:
|
|
return "", nil, fmt.Errorf(
|
|
"%w: %q", errUnsupportedOp, rule.Operator,
|
|
)
|
|
}
|
|
}
|
|
|
|
// buildCondition generates a single SQL condition for a non-genre-
|
|
// subquery rule.
|
|
func buildCondition(
|
|
col string, rule Rule, isNumeric bool,
|
|
) (string, []any, error) {
|
|
switch rule.Operator {
|
|
case "is":
|
|
if isNumeric {
|
|
v, err := parseNumericValue(rule.Field, rule.Operator, rule.Value)
|
|
if err != nil {
|
|
return "", nil, err
|
|
}
|
|
|
|
return col + " = ?", []any{v}, nil
|
|
}
|
|
|
|
return col + " = ? COLLATE NOCASE", []any{rule.Value}, nil
|
|
|
|
case "is_not":
|
|
if isNumeric {
|
|
v, err := parseNumericValue(rule.Field, rule.Operator, rule.Value)
|
|
if err != nil {
|
|
return "", nil, err
|
|
}
|
|
|
|
return col + " != ?", []any{v}, nil
|
|
}
|
|
|
|
return col + " != ? COLLATE NOCASE", []any{rule.Value}, nil
|
|
|
|
case "contains":
|
|
return col + " LIKE ?",
|
|
[]any{"%" + rule.Value + "%"}, nil
|
|
|
|
case "does_not_contain":
|
|
return col + " NOT LIKE ?",
|
|
[]any{"%" + rule.Value + "%"}, nil
|
|
|
|
case "starts_with":
|
|
return col + " LIKE ?",
|
|
[]any{rule.Value + "%"}, nil
|
|
|
|
case "ends_with":
|
|
return col + " LIKE ?",
|
|
[]any{"%" + rule.Value}, nil
|
|
|
|
case "is_any_of":
|
|
var values []string
|
|
|
|
if err := json.Unmarshal(
|
|
[]byte(rule.Value), &values,
|
|
); err != nil {
|
|
return "", nil, fmt.Errorf(
|
|
"field %q: is_any_of value must be a JSON "+
|
|
"string array: %w",
|
|
rule.Field, err,
|
|
)
|
|
}
|
|
|
|
if len(values) == 0 {
|
|
return "", nil, fmt.Errorf(
|
|
"field %q: %w", rule.Field, errEmptyIsAnyOf,
|
|
)
|
|
}
|
|
|
|
placeholders := make([]string, len(values))
|
|
condArgs := make([]any, len(values))
|
|
|
|
for i, v := range values {
|
|
placeholders[i] = "? COLLATE NOCASE"
|
|
condArgs[i] = v
|
|
}
|
|
|
|
return col + " IN (" +
|
|
strings.Join(placeholders, ", ") + ")", condArgs, nil
|
|
|
|
case "greater_than":
|
|
v, err := parseNumericValue(rule.Field, rule.Operator, rule.Value)
|
|
if err != nil {
|
|
return "", nil, err
|
|
}
|
|
|
|
return col + " > ?", []any{v}, nil
|
|
|
|
case "less_than":
|
|
v, err := parseNumericValue(rule.Field, rule.Operator, rule.Value)
|
|
if err != nil {
|
|
return "", nil, err
|
|
}
|
|
|
|
return col + " < ?", []any{v}, nil
|
|
|
|
case "between":
|
|
lo, hi, err := parseBetweenValue(rule.Field, rule.Value)
|
|
if err != nil {
|
|
return "", nil, err
|
|
}
|
|
|
|
return col + " BETWEEN ? AND ?",
|
|
[]any{lo, hi}, nil
|
|
|
|
default:
|
|
return "", nil, fmt.Errorf(
|
|
"%w: %q", errUnsupportedOp, rule.Operator,
|
|
)
|
|
}
|
|
}
|
|
|
|
// parseNumericValue converts a string value to int64 for numeric
|
|
// field comparisons.
|
|
func parseNumericValue(field, op, value string) (int64, error) {
|
|
v, err := strconv.ParseInt(value, 10, 64)
|
|
if err != nil {
|
|
return 0, fmt.Errorf(
|
|
"field %q operator %q: %w: %w",
|
|
field, op, errNotNumeric, err,
|
|
)
|
|
}
|
|
|
|
return v, nil
|
|
}
|
|
|
|
// parseBetweenValue parses "min,max" or JSON ["min","max"] into two
|
|
// integer values.
|
|
func parseBetweenValue(
|
|
field, value string,
|
|
) (int64, int64, error) {
|
|
// Try JSON array first.
|
|
var arr []string
|
|
|
|
if err := json.Unmarshal([]byte(value), &arr); err == nil {
|
|
if len(arr) != 2 {
|
|
return 0, 0, fmt.Errorf(
|
|
"field %q: %w: got %d",
|
|
field, errBetweenCount, len(arr),
|
|
)
|
|
}
|
|
|
|
lo, err := strconv.ParseInt(arr[0], 10, 64)
|
|
if err != nil {
|
|
return 0, 0, fmt.Errorf(
|
|
"field %q between lo: %w: %w",
|
|
field, errNotNumeric, err,
|
|
)
|
|
}
|
|
|
|
hi, err := strconv.ParseInt(arr[1], 10, 64)
|
|
if err != nil {
|
|
return 0, 0, fmt.Errorf(
|
|
"field %q between hi: %w: %w",
|
|
field, errNotNumeric, err,
|
|
)
|
|
}
|
|
|
|
return lo, hi, nil
|
|
}
|
|
|
|
// Fall back to comma-separated.
|
|
parts := strings.SplitN(value, ",", 2)
|
|
if len(parts) != 2 {
|
|
return 0, 0, fmt.Errorf(
|
|
"field %q: %w", field, errBetweenFormat,
|
|
)
|
|
}
|
|
|
|
lo, err := strconv.ParseInt(
|
|
strings.TrimSpace(parts[0]), 10, 64,
|
|
)
|
|
if err != nil {
|
|
return 0, 0, fmt.Errorf(
|
|
"field %q between lo: %w: %w",
|
|
field, errNotNumeric, err,
|
|
)
|
|
}
|
|
|
|
hi, err := strconv.ParseInt(
|
|
strings.TrimSpace(parts[1]), 10, 64,
|
|
)
|
|
if err != nil {
|
|
return 0, 0, fmt.Errorf(
|
|
"field %q between hi: %w: %w",
|
|
field, errNotNumeric, err,
|
|
)
|
|
}
|
|
|
|
return lo, hi, nil
|
|
}
|
|
|
|
// leanTrackQuery is the smart-playlist projection: the same columns
|
|
// the `track_metadata` view would expose minus the correlated-subquery
|
|
// `genre` aggregate that made the view expensive to scan. Genres are
|
|
// batch-loaded after this query returns.
|
|
//
|
|
// Wrapping the joins in a subquery aliased `af` lets WHERE/ORDER BY
|
|
// clauses reference the projected names (`title`, `year`, etc.) the
|
|
// same way they would against the view. SQLite flattens this subquery
|
|
// so the runtime cost is equivalent to querying the underlying tables
|
|
// directly.
|
|
const leanTrackQuery = `SELECT
|
|
af.id,
|
|
af.file_path,
|
|
af.length_milliseconds,
|
|
af.title,
|
|
af.artist_name,
|
|
af.track_number,
|
|
af.disc_number,
|
|
af.album,
|
|
af.year,
|
|
af.composer,
|
|
af.file_type,
|
|
af.sample_rate,
|
|
af.bit_depth,
|
|
af.channels,
|
|
af.bitrate,
|
|
af.file_size,
|
|
af.play_count,
|
|
COALESCE(af.last_played, '') AS last_played
|
|
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,
|
|
af.length_milliseconds,
|
|
af.title,
|
|
af.artist_credit AS artist_name,
|
|
af.track_number,
|
|
af.disc_number,
|
|
COALESCE(al.name, '') AS album,
|
|
-- Two year fields, matching the canonical track_metadata view:
|
|
-- year - the album's original (first-release) year,
|
|
-- the default users filter on. A 1977 album owned
|
|
-- as a 2010s reissue still filters as 1977.
|
|
-- release_year - the year of the specific copy in the library.
|
|
COALESCE(al.original_year, al.year, af.year, 0) AS year,
|
|
COALESCE(al.year, af.year, 0) AS release_year,
|
|
af.composer,
|
|
COALESCE(ft.extension, '') AS file_type,
|
|
af.sample_rate,
|
|
af.bit_depth,
|
|
af.channels,
|
|
af.bitrate,
|
|
af.file_size,
|
|
af.library_id,
|
|
af.play_count,
|
|
af.last_played
|
|
FROM audio_files af
|
|
LEFT JOIN albums al ON al.id = af.album_id
|
|
LEFT JOIN file_types ft ON ft.id = af.file_type_id
|
|
) af`
|
|
|
|
// Evaluate runs the rule set against the library and returns matching
|
|
// tracks. It issues two queries: one lean SELECT over the joined
|
|
// metadata tables (no genre) and a batched follow-up to attach genres
|
|
// to the matched tracks. This avoids the per-row correlated genre
|
|
// subquery in `track_metadata_view`, which scaled with library size
|
|
// rather than result size.
|
|
func Evaluate(
|
|
db *database.DB, ruleSet RuleSet,
|
|
) ([]library.Track, error) {
|
|
start := time.Now()
|
|
logger := db.Logger()
|
|
|
|
where, args, err := BuildWhereClause(ruleSet.Rules, ruleSet.Match)
|
|
if err != nil {
|
|
return nil, fmt.Errorf(
|
|
"smart playlist rule error: %w", err,
|
|
)
|
|
}
|
|
|
|
// Sort-by-genre has no single SQL column to sort on (genres are
|
|
// many-to-one per track). Detect it up front so we can sort in
|
|
// Go after genres are merged.
|
|
sortByGenre := ruleSet.SortField == "genre"
|
|
|
|
// SAFETY: Dynamic WHERE clause built from whitelisted field
|
|
// names and parameterized values only. Sort field is validated
|
|
// against fieldMap. No user-supplied strings are interpolated.
|
|
query := leanTrackQuery
|
|
|
|
if where != "" {
|
|
query += "\nWHERE " + where
|
|
}
|
|
|
|
// Sort.
|
|
applyLimitInSQL := ruleSet.Limit > 0
|
|
|
|
switch {
|
|
case sortByGenre:
|
|
// Sort applied in Go after the batch genre merge. If a LIMIT
|
|
// was requested we also defer it so the ordering is computed
|
|
// over the full candidate set.
|
|
applyLimitInSQL = false
|
|
|
|
case ruleSet.SortField == "random":
|
|
query += "\nORDER BY RANDOM()"
|
|
|
|
case ruleSet.SortField != "":
|
|
sortCol, ok := fieldMap[ruleSet.SortField]
|
|
if !ok {
|
|
return nil, fmt.Errorf(
|
|
"%w: %q", errInvalidSortField,
|
|
ruleSet.SortField,
|
|
)
|
|
}
|
|
|
|
dir := "ASC"
|
|
if strings.EqualFold(ruleSet.SortDir, "DESC") {
|
|
dir = "DESC"
|
|
}
|
|
|
|
query += "\nORDER BY " + sortCol + " " + dir
|
|
}
|
|
|
|
if applyLimitInSQL {
|
|
query += "\nLIMIT ?"
|
|
|
|
args = append(args, ruleSet.Limit)
|
|
}
|
|
|
|
mainStart := time.Now()
|
|
|
|
rows, err := db.QueryContext(query, args...)
|
|
if err != nil {
|
|
return nil, fmt.Errorf(
|
|
"smart playlist query failed: %w", err,
|
|
)
|
|
}
|
|
|
|
tracks, fileIDs, err := scanTracks(rows)
|
|
|
|
_ = rows.Close()
|
|
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
mainDuration := time.Since(mainStart)
|
|
|
|
// Batch-load genres for every matched file id in one query
|
|
// instead of the per-row correlated subquery the view used.
|
|
genreStart := time.Now()
|
|
|
|
genresByFile, err := fetchGenres(db, fileIDs)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
for i, rid := range fileIDs {
|
|
if g, ok := genresByFile[rid]; ok {
|
|
tracks[i].Genre = splitGenres(g)
|
|
}
|
|
}
|
|
|
|
genreDuration := time.Since(genreStart)
|
|
|
|
// Batch-load cover art + MusicBrainz IDs for the matched rows only.
|
|
// These fields are presentation-only (track-row styling); keeping
|
|
// them out of the lean query avoids a per-row correlated subquery
|
|
// and cover-art join over the whole library before WHERE/LIMIT.
|
|
artStart := time.Now()
|
|
|
|
artworkByFile, err := fetchArtwork(db, fileIDs)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
for i, rid := range fileIDs {
|
|
art, ok := artworkByFile[rid]
|
|
if !ok {
|
|
continue
|
|
}
|
|
|
|
tracks[i].ArtistMBID = art.artistMBID
|
|
tracks[i].ReleaseGroupMBID = art.releaseGroupMBID
|
|
tracks[i].RecordingMBID = art.recordingMBID
|
|
|
|
if art.coverArtPath != "" {
|
|
urls := coverart.ResolveURLs(art.coverArtPath)
|
|
tracks[i].CoverArtPath = urls.Original
|
|
tracks[i].CoverArtSmall = urls.Small
|
|
tracks[i].CoverArtMedium = urls.Medium
|
|
tracks[i].CoverArtLarge = urls.Large
|
|
}
|
|
}
|
|
|
|
artDuration := time.Since(artStart)
|
|
|
|
// Apply genre-sort and deferred LIMIT in Go if needed.
|
|
if sortByGenre {
|
|
dir := 1
|
|
if strings.EqualFold(ruleSet.SortDir, "DESC") {
|
|
dir = -1
|
|
}
|
|
|
|
sort.SliceStable(tracks, func(i, j int) bool {
|
|
return dir*strings.Compare(
|
|
strings.Join(tracks[i].Genre, genreDelimiter),
|
|
strings.Join(tracks[j].Genre, genreDelimiter),
|
|
) < 0
|
|
})
|
|
|
|
if ruleSet.Limit > 0 && len(tracks) > ruleSet.Limit {
|
|
tracks = tracks[:ruleSet.Limit]
|
|
}
|
|
}
|
|
|
|
logger.Debug(
|
|
"smart playlist evaluated",
|
|
"tracks", len(tracks),
|
|
"main_ms", mainDuration.Milliseconds(),
|
|
"genres_ms", genreDuration.Milliseconds(),
|
|
"artwork_ms", artDuration.Milliseconds(),
|
|
"total_ms", time.Since(start).Milliseconds(),
|
|
)
|
|
|
|
return tracks, nil
|
|
}
|
|
|
|
// scanTracks reads all rows from a lean-query result into parallel
|
|
// slices: the Track values (minus genres, which are attached later)
|
|
// and the audio file id for each, used for the batched genre fetch.
|
|
func scanTracks(rows *sql.Rows) ([]library.Track, []int64, error) {
|
|
var (
|
|
tracks []library.Track
|
|
fileIDs []int64
|
|
)
|
|
|
|
for rows.Next() {
|
|
var (
|
|
fileID sql.NullInt64
|
|
filePath string
|
|
lengthMs int64
|
|
title string
|
|
artistName string
|
|
trackNumber sql.NullInt64
|
|
discNumber sql.NullInt64
|
|
album string
|
|
year int64
|
|
composer string
|
|
fileType string
|
|
sampleRate int64
|
|
bitDepth int64
|
|
channels int64
|
|
bitrate int64
|
|
fileSize int64
|
|
playCount int64
|
|
lastPlayed string
|
|
)
|
|
|
|
if err := rows.Scan(
|
|
&fileID, &filePath, &lengthMs, &title, &artistName,
|
|
&trackNumber, &discNumber,
|
|
&album, &year, &composer, &fileType,
|
|
&sampleRate, &bitDepth, &channels,
|
|
&bitrate, &fileSize,
|
|
&playCount, &lastPlayed,
|
|
); err != nil {
|
|
return nil, nil, fmt.Errorf(
|
|
"could not scan smart playlist row: %w", err,
|
|
)
|
|
}
|
|
|
|
track := library.Track{
|
|
TrackName: title,
|
|
ArtistName: artistName,
|
|
TrackLength: strconv.FormatInt(lengthMs, 10),
|
|
FilePath: filePath,
|
|
TrackNumber: trackNumber.Int64,
|
|
DiscNumber: discNumber.Int64,
|
|
Album: album,
|
|
Year: year,
|
|
Composer: composer,
|
|
FileType: fileType,
|
|
SampleRate: sampleRate,
|
|
BitDepth: bitDepth,
|
|
Channels: channels,
|
|
Bitrate: bitrate,
|
|
FileSize: fileSize,
|
|
PlayCount: playCount,
|
|
LastPlayed: lastPlayed,
|
|
}
|
|
|
|
tracks = append(tracks, track)
|
|
fileIDs = append(fileIDs, fileID.Int64)
|
|
}
|
|
|
|
if err := rows.Err(); err != nil {
|
|
return nil, nil, fmt.Errorf(
|
|
"smart playlist row iteration error: %w", err,
|
|
)
|
|
}
|
|
|
|
return tracks, fileIDs, nil
|
|
}
|
|
|
|
// fetchGenres batch-loads the GROUP_CONCAT-joined genre string for
|
|
// every file id in ids using a single IN-list query. Returns a
|
|
// map from file id to the concatenated genre string.
|
|
func fetchGenres(
|
|
db *database.DB, ids []int64,
|
|
) (map[int64]string, error) {
|
|
if len(ids) == 0 {
|
|
return nil, nil
|
|
}
|
|
|
|
// Deduplicate to keep the IN list minimal.
|
|
seen := make(map[int64]struct{}, len(ids))
|
|
unique := make([]int64, 0, len(ids))
|
|
|
|
for _, id := range ids {
|
|
if id == 0 {
|
|
continue
|
|
}
|
|
|
|
if _, ok := seen[id]; ok {
|
|
continue
|
|
}
|
|
|
|
seen[id] = struct{}{}
|
|
|
|
unique = append(unique, id)
|
|
}
|
|
|
|
if len(unique) == 0 {
|
|
return nil, nil
|
|
}
|
|
|
|
placeholders := make([]string, len(unique))
|
|
args := make([]any, len(unique))
|
|
|
|
for i, id := range unique {
|
|
placeholders[i] = "?"
|
|
args[i] = id
|
|
}
|
|
|
|
// SAFETY: placeholders are static "?" tokens; every value is
|
|
// parameterized.
|
|
query := `SELECT fg.audio_file_id,
|
|
GROUP_CONCAT(g.name, '` + genreDelimiter + `')
|
|
FROM file_genres fg
|
|
JOIN genres g ON fg.genre_id = g.id
|
|
WHERE fg.audio_file_id IN (` +
|
|
strings.Join(placeholders, ", ") + `)
|
|
GROUP BY fg.audio_file_id`
|
|
|
|
rows, err := db.QueryContext(query, args...)
|
|
if err != nil {
|
|
return nil, fmt.Errorf(
|
|
"smart playlist genre fetch failed: %w", err,
|
|
)
|
|
}
|
|
|
|
defer func() { _ = rows.Close() }()
|
|
|
|
result := make(map[int64]string, len(unique))
|
|
|
|
for rows.Next() {
|
|
var (
|
|
rid int64
|
|
names string
|
|
)
|
|
|
|
if err := rows.Scan(&rid, &names); err != nil {
|
|
return nil, fmt.Errorf(
|
|
"could not scan smart playlist genre row: %w", err,
|
|
)
|
|
}
|
|
|
|
result[rid] = names
|
|
}
|
|
|
|
if err := rows.Err(); err != nil {
|
|
return nil, fmt.Errorf(
|
|
"smart playlist genre iteration error: %w", err,
|
|
)
|
|
}
|
|
|
|
return result, nil
|
|
}
|
|
|
|
// trackArtwork holds the presentation-only cover-art path and
|
|
// MusicBrainz identifiers attached to a matched track after the main
|
|
// filter query, keyed by audio file id.
|
|
type trackArtwork struct {
|
|
coverArtPath string
|
|
artistMBID string
|
|
releaseGroupMBID string
|
|
recordingMBID string
|
|
}
|
|
|
|
// fetchArtwork batch-loads cover-art paths and MusicBrainz IDs for the
|
|
// given file ids in a single IN-list query. These fields drive
|
|
// track-row styling only, so scoping them to the matched result set
|
|
// keeps the cost proportional to results rather than library size.
|
|
func fetchArtwork(
|
|
db *database.DB, ids []int64,
|
|
) (map[int64]trackArtwork, error) {
|
|
if len(ids) == 0 {
|
|
return nil, nil
|
|
}
|
|
|
|
// Deduplicate to keep the IN list minimal.
|
|
seen := make(map[int64]struct{}, len(ids))
|
|
unique := make([]int64, 0, len(ids))
|
|
|
|
for _, id := range ids {
|
|
if id == 0 {
|
|
continue
|
|
}
|
|
|
|
if _, ok := seen[id]; ok {
|
|
continue
|
|
}
|
|
|
|
seen[id] = struct{}{}
|
|
|
|
unique = append(unique, id)
|
|
}
|
|
|
|
if len(unique) == 0 {
|
|
return nil, nil
|
|
}
|
|
|
|
placeholders := make([]string, len(unique))
|
|
|
|
for i := range unique {
|
|
placeholders[i] = "?"
|
|
}
|
|
|
|
inList := strings.Join(placeholders, ", ")
|
|
|
|
// SAFETY: placeholders are static "?" tokens; every value is
|
|
// parameterized.
|
|
query := `SELECT af.id,
|
|
COALESCE(ca.file_path, '') AS cover_art_path,
|
|
COALESCE(ar.mbid, '') AS artist_mbid,
|
|
COALESCE(al.mbid, '') AS release_group_mbid,
|
|
COALESCE(af.recording_mbid, '') AS recording_mbid
|
|
FROM audio_files af
|
|
LEFT JOIN artists ar ON ar.id = af.artist_id
|
|
LEFT JOIN albums al ON al.id = af.album_id
|
|
LEFT JOIN cover_art ca ON ca.id = al.cover_art_id
|
|
WHERE af.id IN (` + inList + `)`
|
|
|
|
args := make([]any, 0, len(unique))
|
|
for _, id := range unique {
|
|
args = append(args, id)
|
|
}
|
|
|
|
rows, err := db.QueryContext(query, args...)
|
|
if err != nil {
|
|
return nil, fmt.Errorf(
|
|
"smart playlist artwork fetch failed: %w", err,
|
|
)
|
|
}
|
|
|
|
defer func() { _ = rows.Close() }()
|
|
|
|
result := make(map[int64]trackArtwork, len(unique))
|
|
|
|
for rows.Next() {
|
|
var (
|
|
rid int64
|
|
art trackArtwork
|
|
)
|
|
|
|
if err := rows.Scan(
|
|
&rid, &art.coverArtPath, &art.artistMBID,
|
|
&art.releaseGroupMBID, &art.recordingMBID,
|
|
); err != nil {
|
|
return nil, fmt.Errorf(
|
|
"could not scan smart playlist artwork row: %w", err,
|
|
)
|
|
}
|
|
|
|
result[rid] = art
|
|
}
|
|
|
|
if err := rows.Err(); err != nil {
|
|
return nil, fmt.Errorf(
|
|
"smart playlist artwork iteration error: %w", err,
|
|
)
|
|
}
|
|
|
|
return result, nil
|
|
}
|
|
|
|
// ParseRuleSet parses a JSON string into a validated RuleSet.
|
|
func ParseRuleSet(jsonStr string) (RuleSet, error) {
|
|
var rs RuleSet
|
|
|
|
if err := json.Unmarshal(
|
|
[]byte(jsonStr), &rs,
|
|
); err != nil {
|
|
return RuleSet{}, fmt.Errorf(
|
|
"invalid smart playlist rules JSON: %w", err,
|
|
)
|
|
}
|
|
|
|
// A match nobody recognises would otherwise fall through to AND,
|
|
// which is a playlist quietly returning the wrong tracks rather
|
|
// than refusing to be saved. This is the only place a rule set
|
|
// enters the backend, so it is the only place that has to ask.
|
|
if rs.Match != "" && rs.Match != MatchAll && rs.Match != MatchAny {
|
|
return RuleSet{}, fmt.Errorf(
|
|
"%w: %q", errInvalidMatch, rs.Match,
|
|
)
|
|
}
|
|
|
|
return rs, nil
|
|
}
|
|
|
|
// splitGenres splits a GROUP_CONCAT genre string into individual
|
|
// genre names. An empty string returns nil.
|
|
func splitGenres(concatenated string) []string {
|
|
if concatenated == "" {
|
|
return nil
|
|
}
|
|
|
|
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)
|
|
}
|