mirror of
https://github.com/navidrome/navidrome.git
synced 2026-06-19 07:37:15 +00:00
refactor: move smart playlist criteria SQL to persistence (#5408)
* refactor: move criteria SQL generation to persistence Keep model/criteria as a domain DSL with JSON parsing, field metadata, expression traversal, and child playlist extraction only. Move smart playlist SQL translation, sort SQL, and join planning into persistence behind smartPlaylistCriteria so repository code uses a small query-building API. * refactor: simplify criteria translator metadata Use generic helper functions for criteria operator maps so the SQL translator can pass named criteria map types directly. Remove unused pseudo-field metadata from the criteria field API while preserving special field name lookup. * test: add coverage check for criteria-to-SQL field mappings Add a test that iterates all fields registered in the criteria package and verifies that every non-tag/non-role field has a corresponding entry in the persistence layer's smartPlaylistFields map. This prevents silent drift between the domain field registry and the SQL translation layer. Also adds an AllFieldNames() function to the criteria package to support field enumeration from outside the package.
This commit is contained in:
+5
-109
@@ -1,17 +1,16 @@
|
||||
// Package criteria implements a Criteria API based on Masterminds/squirrel
|
||||
// Package criteria implements the smart playlist criteria DSL.
|
||||
package criteria
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/Masterminds/squirrel"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
)
|
||||
|
||||
type Expression = squirrel.Sqlizer
|
||||
type Expression interface {
|
||||
criteriaExpression()
|
||||
}
|
||||
|
||||
type Criteria struct {
|
||||
Expression
|
||||
@@ -49,115 +48,12 @@ func (c Criteria) IsPercentageLimit() bool {
|
||||
return c.Limit == 0 && c.LimitPercent > 0 && c.LimitPercent <= 100
|
||||
}
|
||||
|
||||
func (c Criteria) OrderBy() string {
|
||||
if c.Sort == "" {
|
||||
c.Sort = "title"
|
||||
}
|
||||
|
||||
order := strings.ToLower(strings.TrimSpace(c.Order))
|
||||
if order != "" && order != "asc" && order != "desc" {
|
||||
log.Error("Invalid value in 'order' field. Valid values: 'asc', 'desc'", "order", c.Order)
|
||||
order = ""
|
||||
}
|
||||
|
||||
parts := strings.Split(c.Sort, ",")
|
||||
fields := make([]string, 0, len(parts))
|
||||
|
||||
for _, p := range parts {
|
||||
p = strings.TrimSpace(p)
|
||||
if p == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
dir := "asc"
|
||||
if strings.HasPrefix(p, "+") || strings.HasPrefix(p, "-") {
|
||||
if strings.HasPrefix(p, "-") {
|
||||
dir = "desc"
|
||||
}
|
||||
p = strings.TrimSpace(p[1:])
|
||||
}
|
||||
|
||||
sortField := strings.ToLower(p)
|
||||
f := fieldMap[sortField]
|
||||
if f == nil {
|
||||
log.Error("Invalid field in 'sort' field", "sort", sortField)
|
||||
continue
|
||||
}
|
||||
|
||||
var mapped string
|
||||
|
||||
if f.order != "" {
|
||||
mapped = f.order
|
||||
} else if f.isTag {
|
||||
// Use the actual field name (handles aliases like albumtype -> releasetype)
|
||||
tagName := sortField
|
||||
if f.field != "" {
|
||||
tagName = f.field
|
||||
}
|
||||
mapped = "COALESCE(json_extract(media_file.tags, '$." + tagName + "[0].value'), '')"
|
||||
} else if f.isRole {
|
||||
mapped = "COALESCE(json_extract(media_file.participants, '$." + sortField + "[0].name'), '')"
|
||||
} else {
|
||||
mapped = f.field
|
||||
}
|
||||
if f.numeric {
|
||||
mapped = fmt.Sprintf("CAST(%s AS REAL)", mapped)
|
||||
}
|
||||
// If the global 'order' field is set to 'desc', reverse the default or field-specific sort direction.
|
||||
// This ensures that the global order applies consistently across all fields.
|
||||
if order == "desc" {
|
||||
if dir == "asc" {
|
||||
dir = "desc"
|
||||
} else {
|
||||
dir = "asc"
|
||||
}
|
||||
}
|
||||
|
||||
fields = append(fields, mapped+" "+dir)
|
||||
}
|
||||
|
||||
return strings.Join(fields, ", ")
|
||||
}
|
||||
|
||||
func (c Criteria) ToSql() (sql string, args []any, err error) {
|
||||
return c.Expression.ToSql()
|
||||
}
|
||||
|
||||
// ExpressionJoins returns only the JOINs needed by the WHERE-clause expression,
|
||||
// excluding any JOINs required solely for sorting. This is useful for COUNT
|
||||
// queries where sort order is irrelevant.
|
||||
func (c Criteria) ExpressionJoins() JoinType {
|
||||
if c.Expression == nil {
|
||||
return JoinNone
|
||||
}
|
||||
return extractJoinTypes(c.Expression)
|
||||
}
|
||||
|
||||
// RequiredJoins inspects the expression tree and Sort field to determine which
|
||||
// additional JOINs are needed when evaluating this criteria.
|
||||
func (c Criteria) RequiredJoins() JoinType {
|
||||
result := JoinNone
|
||||
if c.Expression != nil {
|
||||
result |= extractJoinTypes(c.Expression)
|
||||
}
|
||||
// Also check Sort fields
|
||||
if c.Sort != "" {
|
||||
for _, p := range strings.Split(c.Sort, ",") {
|
||||
p = strings.TrimSpace(p)
|
||||
p = strings.TrimLeft(p, "+-")
|
||||
p = strings.TrimSpace(p)
|
||||
result |= fieldJoinType(p)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func (c Criteria) ChildPlaylistIds() []string {
|
||||
if c.Expression == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
if parent := c.Expression.(interface{ ChildPlaylistIds() (ids []string) }); parent != nil {
|
||||
if parent, ok := c.Expression.(interface{ ChildPlaylistIds() (ids []string) }); ok {
|
||||
return parent.ChildPlaylistIds()
|
||||
}
|
||||
|
||||
|
||||
@@ -65,16 +65,6 @@ var _ = Describe("Criteria", func() {
|
||||
}
|
||||
jsonObj = b.String()
|
||||
})
|
||||
It("generates valid SQL", func() {
|
||||
sql, args, err := goObj.ToSql()
|
||||
gomega.Expect(err).ToNot(gomega.HaveOccurred())
|
||||
gomega.Expect(sql).To(gomega.Equal(
|
||||
`(media_file.title LIKE ? AND media_file.title NOT LIKE ? ` +
|
||||
`AND (not exists (select 1 from json_tree(media_file.participants, '$.artist') where key='name' and value = ?) ` +
|
||||
`OR media_file.album = ?) AND (media_file.comment LIKE ? AND (media_file.year >= ? AND media_file.year <= ?) ` +
|
||||
`AND not exists (select 1 from json_tree(media_file.tags, '$.genre') where key='value' and value = ?) AND COALESCE(album_annotation.rating, 0) > ?))`))
|
||||
gomega.Expect(args).To(gomega.HaveExactElements("%love%", "%hate%", "u2", "best of", "this%", 1980, 1990, "Rock", 3))
|
||||
})
|
||||
It("marshals to JSON", func() {
|
||||
j, err := json.Marshal(goObj)
|
||||
gomega.Expect(err).ToNot(gomega.HaveOccurred())
|
||||
@@ -88,201 +78,6 @@ var _ = Describe("Criteria", func() {
|
||||
gomega.Expect(err).ToNot(gomega.HaveOccurred())
|
||||
gomega.Expect(string(j)).To(gomega.Equal(jsonObj))
|
||||
})
|
||||
Describe("OrderBy", func() {
|
||||
It("sorts by regular fields", func() {
|
||||
gomega.Expect(goObj.OrderBy()).To(gomega.Equal("media_file.title asc"))
|
||||
})
|
||||
|
||||
It("sorts by tag fields", func() {
|
||||
goObj.Sort = "genre"
|
||||
gomega.Expect(goObj.OrderBy()).To(
|
||||
gomega.Equal(
|
||||
"COALESCE(json_extract(media_file.tags, '$.genre[0].value'), '') asc",
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
It("sorts by role fields", func() {
|
||||
goObj.Sort = "artist"
|
||||
gomega.Expect(goObj.OrderBy()).To(
|
||||
gomega.Equal(
|
||||
"COALESCE(json_extract(media_file.participants, '$.artist[0].name'), '') asc",
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
It("casts numeric tags when sorting", func() {
|
||||
AddTagNames([]string{"rate"})
|
||||
AddNumericTags([]string{"rate"})
|
||||
goObj.Sort = "rate"
|
||||
gomega.Expect(goObj.OrderBy()).To(
|
||||
gomega.Equal("CAST(COALESCE(json_extract(media_file.tags, '$.rate[0].value'), '') AS REAL) asc"),
|
||||
)
|
||||
})
|
||||
|
||||
It("sorts by albumtype alias (resolves to releasetype)", func() {
|
||||
AddTagNames([]string{"releasetype"})
|
||||
goObj.Sort = "albumtype"
|
||||
gomega.Expect(goObj.OrderBy()).To(
|
||||
gomega.Equal(
|
||||
"COALESCE(json_extract(media_file.tags, '$.releasetype[0].value'), '') asc",
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
It("sorts by random", func() {
|
||||
newObj := goObj
|
||||
newObj.Sort = "random"
|
||||
gomega.Expect(newObj.OrderBy()).To(gomega.Equal("random() asc"))
|
||||
})
|
||||
|
||||
It("sorts by multiple fields", func() {
|
||||
goObj.Sort = "title,-rating"
|
||||
gomega.Expect(goObj.OrderBy()).To(gomega.Equal(
|
||||
"media_file.title asc, COALESCE(annotation.rating, 0) desc",
|
||||
))
|
||||
})
|
||||
|
||||
It("reverts order when order is desc", func() {
|
||||
goObj.Sort = "-date,artist"
|
||||
goObj.Order = "desc"
|
||||
gomega.Expect(goObj.OrderBy()).To(gomega.Equal(
|
||||
"media_file.date asc, COALESCE(json_extract(media_file.participants, '$.artist[0].name'), '') desc",
|
||||
))
|
||||
})
|
||||
|
||||
It("ignores invalid sort fields", func() {
|
||||
goObj.Sort = "bogus,title"
|
||||
gomega.Expect(goObj.OrderBy()).To(gomega.Equal(
|
||||
"media_file.title asc",
|
||||
))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Context("with artist roles", func() {
|
||||
BeforeEach(func() {
|
||||
goObj = Criteria{
|
||||
Expression: All{
|
||||
Is{"artist": "The Beatles"},
|
||||
Contains{"composer": "Lennon"},
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
It("generates valid SQL", func() {
|
||||
sql, args, err := goObj.ToSql()
|
||||
gomega.Expect(err).ToNot(gomega.HaveOccurred())
|
||||
gomega.Expect(sql).To(gomega.Equal(
|
||||
`(exists (select 1 from json_tree(media_file.participants, '$.artist') where key='name' and value = ?) AND ` +
|
||||
`exists (select 1 from json_tree(media_file.participants, '$.composer') where key='name' and value LIKE ?))`,
|
||||
))
|
||||
gomega.Expect(args).To(gomega.HaveExactElements("The Beatles", "%Lennon%"))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("ExpressionJoins", func() {
|
||||
It("excludes sort-only joins", func() {
|
||||
c := Criteria{
|
||||
Expression: All{
|
||||
Contains{"title": "love"},
|
||||
},
|
||||
Sort: "albumRating",
|
||||
}
|
||||
gomega.Expect(c.ExpressionJoins()).To(gomega.Equal(JoinNone))
|
||||
gomega.Expect(c.RequiredJoins().Has(JoinAlbumAnnotation)).To(gomega.BeTrue())
|
||||
})
|
||||
|
||||
It("includes expression-based joins", func() {
|
||||
c := Criteria{
|
||||
Expression: All{
|
||||
Gt{"albumRating": 3},
|
||||
},
|
||||
}
|
||||
gomega.Expect(c.ExpressionJoins().Has(JoinAlbumAnnotation)).To(gomega.BeTrue())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("RequiredJoins", func() {
|
||||
It("returns JoinNone when no annotation fields are used", func() {
|
||||
c := Criteria{
|
||||
Expression: All{
|
||||
Contains{"title": "love"},
|
||||
},
|
||||
}
|
||||
gomega.Expect(c.RequiredJoins()).To(gomega.Equal(JoinNone))
|
||||
})
|
||||
It("returns JoinNone for media_file annotation fields", func() {
|
||||
c := Criteria{
|
||||
Expression: All{
|
||||
Is{"loved": true},
|
||||
Gt{"playCount": 5},
|
||||
},
|
||||
}
|
||||
gomega.Expect(c.RequiredJoins()).To(gomega.Equal(JoinNone))
|
||||
})
|
||||
It("returns JoinAlbumAnnotation for album annotation fields", func() {
|
||||
c := Criteria{
|
||||
Expression: All{
|
||||
Gt{"albumRating": 3},
|
||||
},
|
||||
}
|
||||
gomega.Expect(c.RequiredJoins()).To(gomega.Equal(JoinAlbumAnnotation))
|
||||
})
|
||||
It("returns JoinArtistAnnotation for artist annotation fields", func() {
|
||||
c := Criteria{
|
||||
Expression: All{
|
||||
Is{"artistLoved": true},
|
||||
},
|
||||
}
|
||||
gomega.Expect(c.RequiredJoins()).To(gomega.Equal(JoinArtistAnnotation))
|
||||
})
|
||||
It("returns both join types when both are used", func() {
|
||||
c := Criteria{
|
||||
Expression: All{
|
||||
Gt{"albumRating": 3},
|
||||
Is{"artistLoved": true},
|
||||
},
|
||||
}
|
||||
j := c.RequiredJoins()
|
||||
gomega.Expect(j.Has(JoinAlbumAnnotation)).To(gomega.BeTrue())
|
||||
gomega.Expect(j.Has(JoinArtistAnnotation)).To(gomega.BeTrue())
|
||||
})
|
||||
It("detects join types in nested expressions", func() {
|
||||
c := Criteria{
|
||||
Expression: All{
|
||||
Any{
|
||||
All{
|
||||
Is{"albumLoved": true},
|
||||
},
|
||||
},
|
||||
Any{
|
||||
Gt{"artistPlayCount": 10},
|
||||
},
|
||||
},
|
||||
}
|
||||
j := c.RequiredJoins()
|
||||
gomega.Expect(j.Has(JoinAlbumAnnotation)).To(gomega.BeTrue())
|
||||
gomega.Expect(j.Has(JoinArtistAnnotation)).To(gomega.BeTrue())
|
||||
})
|
||||
It("detects join types from Sort field", func() {
|
||||
c := Criteria{
|
||||
Expression: All{
|
||||
Contains{"title": "love"},
|
||||
},
|
||||
Sort: "albumRating",
|
||||
}
|
||||
gomega.Expect(c.RequiredJoins().Has(JoinAlbumAnnotation)).To(gomega.BeTrue())
|
||||
})
|
||||
It("detects join types from Sort field with direction prefix", func() {
|
||||
c := Criteria{
|
||||
Expression: All{
|
||||
Contains{"title": "love"},
|
||||
},
|
||||
Sort: "-artistRating",
|
||||
}
|
||||
gomega.Expect(c.RequiredJoins().Has(JoinArtistAnnotation)).To(gomega.BeTrue())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("LimitPercent", func() {
|
||||
@@ -470,5 +265,9 @@ var _ = Describe("Criteria", func() {
|
||||
ids := Criteria{}.ChildPlaylistIds()
|
||||
gomega.Expect(ids).To(gomega.BeEmpty())
|
||||
})
|
||||
It("returns empty list for leaf expressions", func() {
|
||||
ids := Criteria{Expression: Is{"title": "Low Rider"}}.ChildPlaylistIds()
|
||||
gomega.Expect(ids).To(gomega.BeEmpty())
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
+110
-258
@@ -1,280 +1,133 @@
|
||||
package criteria
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"reflect"
|
||||
"strings"
|
||||
import "strings"
|
||||
|
||||
"github.com/Masterminds/squirrel"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
)
|
||||
|
||||
// JoinType is a bitmask indicating which additional JOINs are needed by a smart playlist expression.
|
||||
type JoinType int
|
||||
|
||||
const (
|
||||
JoinNone JoinType = 0
|
||||
JoinAlbumAnnotation JoinType = 1 << iota
|
||||
JoinArtistAnnotation
|
||||
)
|
||||
|
||||
// Has returns true if j contains all bits in other.
|
||||
func (j JoinType) Has(other JoinType) bool { return j&other != 0 }
|
||||
|
||||
var fieldMap = map[string]*mappedField{
|
||||
"title": {field: "media_file.title"},
|
||||
"album": {field: "media_file.album"},
|
||||
"hascoverart": {field: "media_file.has_cover_art"},
|
||||
"tracknumber": {field: "media_file.track_number"},
|
||||
"discnumber": {field: "media_file.disc_number"},
|
||||
"year": {field: "media_file.year"},
|
||||
"date": {field: "media_file.date", alias: "recordingdate"},
|
||||
"originalyear": {field: "media_file.original_year"},
|
||||
"originaldate": {field: "media_file.original_date"},
|
||||
"releaseyear": {field: "media_file.release_year"},
|
||||
"releasedate": {field: "media_file.release_date"},
|
||||
"size": {field: "media_file.size"},
|
||||
"compilation": {field: "media_file.compilation"},
|
||||
"missing": {field: "media_file.missing"},
|
||||
"explicitstatus": {field: "media_file.explicit_status"},
|
||||
"dateadded": {field: "media_file.created_at"},
|
||||
"datemodified": {field: "media_file.updated_at"},
|
||||
"discsubtitle": {field: "media_file.disc_subtitle"},
|
||||
"comment": {field: "media_file.comment"},
|
||||
"lyrics": {field: "media_file.lyrics"},
|
||||
"sorttitle": {field: "media_file.sort_title"},
|
||||
"sortalbum": {field: "media_file.sort_album_name"},
|
||||
"sortartist": {field: "media_file.sort_artist_name"},
|
||||
"sortalbumartist": {field: "media_file.sort_album_artist_name"},
|
||||
"albumcomment": {field: "media_file.mbz_album_comment"},
|
||||
"catalognumber": {field: "media_file.catalog_num"},
|
||||
"filepath": {field: "media_file.path"},
|
||||
"filetype": {field: "media_file.suffix"},
|
||||
"codec": {field: "media_file.codec"},
|
||||
"duration": {field: "media_file.duration"},
|
||||
"bitrate": {field: "media_file.bit_rate"},
|
||||
"bitdepth": {field: "media_file.bit_depth"},
|
||||
"samplerate": {field: "media_file.sample_rate"},
|
||||
"bpm": {field: "media_file.bpm"},
|
||||
"channels": {field: "media_file.channels"},
|
||||
"loved": {field: "COALESCE(annotation.starred, false)"},
|
||||
"dateloved": {field: "annotation.starred_at"},
|
||||
"lastplayed": {field: "annotation.play_date"},
|
||||
"daterated": {field: "annotation.rated_at"},
|
||||
"playcount": {field: "COALESCE(annotation.play_count, 0)"},
|
||||
"rating": {field: "COALESCE(annotation.rating, 0)"},
|
||||
"averagerating": {field: "media_file.average_rating", numeric: true},
|
||||
"albumrating": {field: "COALESCE(album_annotation.rating, 0)", joinType: JoinAlbumAnnotation},
|
||||
"albumloved": {field: "COALESCE(album_annotation.starred, false)", joinType: JoinAlbumAnnotation},
|
||||
"albumplaycount": {field: "COALESCE(album_annotation.play_count, 0)", joinType: JoinAlbumAnnotation},
|
||||
"albumlastplayed": {field: "album_annotation.play_date", joinType: JoinAlbumAnnotation},
|
||||
"albumdateloved": {field: "album_annotation.starred_at", joinType: JoinAlbumAnnotation},
|
||||
"albumdaterated": {field: "album_annotation.rated_at", joinType: JoinAlbumAnnotation},
|
||||
|
||||
"artistrating": {field: "COALESCE(artist_annotation.rating, 0)", joinType: JoinArtistAnnotation},
|
||||
"artistloved": {field: "COALESCE(artist_annotation.starred, false)", joinType: JoinArtistAnnotation},
|
||||
"artistplaycount": {field: "COALESCE(artist_annotation.play_count, 0)", joinType: JoinArtistAnnotation},
|
||||
"artistlastplayed": {field: "artist_annotation.play_date", joinType: JoinArtistAnnotation},
|
||||
"artistdateloved": {field: "artist_annotation.starred_at", joinType: JoinArtistAnnotation},
|
||||
"artistdaterated": {field: "artist_annotation.rated_at", joinType: JoinArtistAnnotation},
|
||||
|
||||
"mbz_album_id": {field: "media_file.mbz_album_id"},
|
||||
"mbz_album_artist_id": {field: "media_file.mbz_album_artist_id"},
|
||||
"mbz_artist_id": {field: "media_file.mbz_artist_id"},
|
||||
"mbz_recording_id": {field: "media_file.mbz_recording_id"},
|
||||
"mbz_release_track_id": {field: "media_file.mbz_release_track_id"},
|
||||
"mbz_release_group_id": {field: "media_file.mbz_release_group_id"},
|
||||
"library_id": {field: "media_file.library_id", numeric: true},
|
||||
|
||||
// Backward compatibility: albumtype is an alias for releasetype tag
|
||||
"albumtype": {field: "releasetype", isTag: true},
|
||||
|
||||
// special fields
|
||||
"random": {field: "", order: "random()"}, // pseudo-field for random sorting
|
||||
"value": {field: "value"}, // pseudo-field for tag and roles values
|
||||
// FieldInfo describes a criteria field without tying it to persistence details.
|
||||
type FieldInfo struct {
|
||||
Name string
|
||||
IsTag bool
|
||||
IsRole bool
|
||||
Numeric bool
|
||||
}
|
||||
|
||||
type mappedField struct {
|
||||
field string
|
||||
order string
|
||||
isRole bool // true if the field is a role (e.g. "artist", "composer", "conductor", etc.)
|
||||
isTag bool // true if the field is a tag imported from the file metadata
|
||||
alias string // name from `mappings.yml` that may differ from the name used in the smart playlist
|
||||
numeric bool // true if the field/tag should be treated as numeric
|
||||
joinType JoinType // which additional JOINs this field requires
|
||||
var fieldMap = map[string]*fieldMetadata{
|
||||
"title": {name: "title"},
|
||||
"album": {name: "album"},
|
||||
"hascoverart": {name: "hascoverart"},
|
||||
"tracknumber": {name: "tracknumber"},
|
||||
"discnumber": {name: "discnumber"},
|
||||
"year": {name: "year"},
|
||||
"date": {name: "date", alias: "recordingdate"},
|
||||
"originalyear": {name: "originalyear"},
|
||||
"originaldate": {name: "originaldate"},
|
||||
"releaseyear": {name: "releaseyear"},
|
||||
"releasedate": {name: "releasedate"},
|
||||
"size": {name: "size"},
|
||||
"compilation": {name: "compilation"},
|
||||
"missing": {name: "missing"},
|
||||
"explicitstatus": {name: "explicitstatus"},
|
||||
"dateadded": {name: "dateadded"},
|
||||
"datemodified": {name: "datemodified"},
|
||||
"discsubtitle": {name: "discsubtitle"},
|
||||
"comment": {name: "comment"},
|
||||
"lyrics": {name: "lyrics"},
|
||||
"sorttitle": {name: "sorttitle"},
|
||||
"sortalbum": {name: "sortalbum"},
|
||||
"sortartist": {name: "sortartist"},
|
||||
"sortalbumartist": {name: "sortalbumartist"},
|
||||
"albumcomment": {name: "albumcomment"},
|
||||
"catalognumber": {name: "catalognumber"},
|
||||
"filepath": {name: "filepath"},
|
||||
"filetype": {name: "filetype"},
|
||||
"codec": {name: "codec"},
|
||||
"duration": {name: "duration"},
|
||||
"bitrate": {name: "bitrate"},
|
||||
"bitdepth": {name: "bitdepth"},
|
||||
"samplerate": {name: "samplerate"},
|
||||
"bpm": {name: "bpm"},
|
||||
"channels": {name: "channels"},
|
||||
"loved": {name: "loved"},
|
||||
"dateloved": {name: "dateloved"},
|
||||
"lastplayed": {name: "lastplayed"},
|
||||
"daterated": {name: "daterated"},
|
||||
"playcount": {name: "playcount"},
|
||||
"rating": {name: "rating"},
|
||||
"averagerating": {name: "averagerating", numeric: true},
|
||||
"albumrating": {name: "albumrating"},
|
||||
"albumloved": {name: "albumloved"},
|
||||
"albumplaycount": {name: "albumplaycount"},
|
||||
"albumlastplayed": {name: "albumlastplayed"},
|
||||
"albumdateloved": {name: "albumdateloved"},
|
||||
"albumdaterated": {name: "albumdaterated"},
|
||||
"artistrating": {name: "artistrating"},
|
||||
"artistloved": {name: "artistloved"},
|
||||
"artistplaycount": {name: "artistplaycount"},
|
||||
"artistlastplayed": {name: "artistlastplayed"},
|
||||
"artistdateloved": {name: "artistdateloved"},
|
||||
"artistdaterated": {name: "artistdaterated"},
|
||||
"mbz_album_id": {name: "mbz_album_id"},
|
||||
"mbz_album_artist_id": {name: "mbz_album_artist_id"},
|
||||
"mbz_artist_id": {name: "mbz_artist_id"},
|
||||
"mbz_recording_id": {name: "mbz_recording_id"},
|
||||
"mbz_release_track_id": {name: "mbz_release_track_id"},
|
||||
"mbz_release_group_id": {name: "mbz_release_group_id"},
|
||||
"library_id": {name: "library_id", numeric: true},
|
||||
|
||||
// Backward compatibility: albumtype is an alias for the releasetype tag.
|
||||
"albumtype": {name: "releasetype", isTag: true},
|
||||
|
||||
"random": {name: "random"},
|
||||
"value": {name: "value"},
|
||||
}
|
||||
|
||||
func mapFields(expr map[string]any) map[string]any {
|
||||
m := make(map[string]any)
|
||||
for f, v := range expr {
|
||||
if dbf := fieldMap[strings.ToLower(f)]; dbf != nil && dbf.field != "" {
|
||||
m[dbf.field] = v
|
||||
} else {
|
||||
log.Error("Invalid field in criteria", "field", f)
|
||||
}
|
||||
type fieldMetadata struct {
|
||||
name string
|
||||
isRole bool
|
||||
isTag bool
|
||||
alias string
|
||||
numeric bool
|
||||
}
|
||||
|
||||
// AllFieldNames returns the names of all registered criteria fields.
|
||||
func AllFieldNames() []string {
|
||||
names := make([]string, 0, len(fieldMap))
|
||||
for name := range fieldMap {
|
||||
names = append(names, name)
|
||||
}
|
||||
return m
|
||||
return names
|
||||
}
|
||||
|
||||
// mapExpr maps a normal field expression to a specific type of expression (tag or role).
|
||||
// This is required because tags are handled differently than other fields,
|
||||
// as they are stored as a JSON column in the database.
|
||||
func mapExpr(expr squirrel.Sqlizer, negate bool, exprFunc func(string, squirrel.Sqlizer, bool) squirrel.Sqlizer) squirrel.Sqlizer {
|
||||
rv := reflect.ValueOf(expr)
|
||||
if rv.Kind() != reflect.Map || rv.Type().Key().Kind() != reflect.String {
|
||||
log.Fatal(fmt.Sprintf("expr is not a map-based operator: %T", expr))
|
||||
// LookupField returns semantic metadata for a criteria field name.
|
||||
func LookupField(name string) (FieldInfo, bool) {
|
||||
f, ok := fieldMap[strings.ToLower(name)]
|
||||
if !ok {
|
||||
return FieldInfo{}, false
|
||||
}
|
||||
|
||||
// Extract the field name and value, then build a new map keyed by "value"
|
||||
// for the inner condition. The original map is left untouched so that
|
||||
// ToSql can be called multiple times without corruption.
|
||||
var k string
|
||||
var v any
|
||||
for _, key := range rv.MapKeys() {
|
||||
k = key.String()
|
||||
v = rv.MapIndex(key).Interface()
|
||||
break // only one key is expected (and supported)
|
||||
}
|
||||
|
||||
// Create a new map-based expression with "value" as the key, matching the
|
||||
// column name inside json_tree subqueries.
|
||||
newMap := reflect.MakeMap(rv.Type())
|
||||
newMap.SetMapIndex(reflect.ValueOf("value"), reflect.ValueOf(v))
|
||||
newExpr := newMap.Interface().(squirrel.Sqlizer)
|
||||
|
||||
return exprFunc(k, newExpr, negate)
|
||||
}
|
||||
|
||||
// mapTagExpr maps a normal field expression to a tag expression.
|
||||
func mapTagExpr(expr squirrel.Sqlizer, negate bool) squirrel.Sqlizer {
|
||||
return mapExpr(expr, negate, tagExpr)
|
||||
}
|
||||
|
||||
// mapRoleExpr maps a normal field expression to an artist role expression.
|
||||
func mapRoleExpr(expr squirrel.Sqlizer, negate bool) squirrel.Sqlizer {
|
||||
return mapExpr(expr, negate, roleExpr)
|
||||
}
|
||||
|
||||
func isTagExpr(expr map[string]any) bool {
|
||||
for f := range expr {
|
||||
if f2, ok := fieldMap[strings.ToLower(f)]; ok && f2.isTag {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func isRoleExpr(expr map[string]any) bool {
|
||||
for f := range expr {
|
||||
if f2, ok := fieldMap[strings.ToLower(f)]; ok && f2.isRole {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func tagExpr(tag string, cond squirrel.Sqlizer, negate bool) squirrel.Sqlizer {
|
||||
return tagCond{tag: tag, cond: cond, not: negate}
|
||||
}
|
||||
|
||||
type tagCond struct {
|
||||
tag string
|
||||
cond squirrel.Sqlizer
|
||||
not bool
|
||||
}
|
||||
|
||||
func (e tagCond) ToSql() (string, []any, error) {
|
||||
cond, args, err := e.cond.ToSql()
|
||||
|
||||
// Resolve the actual tag name (handles aliases like albumtype -> releasetype)
|
||||
tagName := e.tag
|
||||
if fm, ok := fieldMap[e.tag]; ok {
|
||||
if fm.field != "" {
|
||||
tagName = fm.field
|
||||
}
|
||||
if fm.numeric {
|
||||
cond = strings.ReplaceAll(cond, "value", "CAST(value AS REAL)")
|
||||
}
|
||||
}
|
||||
|
||||
cond = fmt.Sprintf("exists (select 1 from json_tree(media_file.tags, '$.%s') where key='value' and %s)",
|
||||
tagName, cond)
|
||||
if e.not {
|
||||
cond = "not " + cond
|
||||
}
|
||||
return cond, args, err
|
||||
}
|
||||
|
||||
func roleExpr(role string, cond squirrel.Sqlizer, negate bool) squirrel.Sqlizer {
|
||||
return roleCond{role: role, cond: cond, not: negate}
|
||||
}
|
||||
|
||||
type roleCond struct {
|
||||
role string
|
||||
cond squirrel.Sqlizer
|
||||
not bool
|
||||
}
|
||||
|
||||
func (e roleCond) ToSql() (string, []any, error) {
|
||||
cond, args, err := e.cond.ToSql()
|
||||
cond = fmt.Sprintf(`exists (select 1 from json_tree(media_file.participants, '$.%s') where key='name' and %s)`,
|
||||
e.role, cond)
|
||||
if e.not {
|
||||
cond = "not " + cond
|
||||
}
|
||||
return cond, args, err
|
||||
}
|
||||
|
||||
// fieldJoinType returns the JoinType for a given field name (case-insensitive).
|
||||
func fieldJoinType(name string) JoinType {
|
||||
if f, ok := fieldMap[strings.ToLower(name)]; ok {
|
||||
return f.joinType
|
||||
}
|
||||
return JoinNone
|
||||
}
|
||||
|
||||
// extractJoinTypes walks an expression tree and collects all required JoinType flags.
|
||||
func extractJoinTypes(expr any) JoinType {
|
||||
result := JoinNone
|
||||
switch e := expr.(type) {
|
||||
case All:
|
||||
for _, sub := range e {
|
||||
result |= extractJoinTypes(sub)
|
||||
}
|
||||
case Any:
|
||||
for _, sub := range e {
|
||||
result |= extractJoinTypes(sub)
|
||||
}
|
||||
default:
|
||||
// Leaf expression: use reflection to check if it's a map with field names
|
||||
rv := reflect.ValueOf(expr)
|
||||
if rv.Kind() == reflect.Map && rv.Type().Key().Kind() == reflect.String {
|
||||
for _, key := range rv.MapKeys() {
|
||||
result |= fieldJoinType(key.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
return result
|
||||
return FieldInfo{
|
||||
Name: f.name,
|
||||
IsTag: f.isTag,
|
||||
IsRole: f.isRole,
|
||||
Numeric: f.numeric,
|
||||
}, true
|
||||
}
|
||||
|
||||
// AddRoles adds roles to the field map. This is used to add all artist roles to the field map, so they can be used in
|
||||
// smart playlists. If a role already exists in the field map, it is ignored, so calls to this function are idempotent.
|
||||
// smart playlists.
|
||||
func AddRoles(roles []string) {
|
||||
for _, role := range roles {
|
||||
name := strings.ToLower(role)
|
||||
if _, ok := fieldMap[name]; ok {
|
||||
continue
|
||||
}
|
||||
fieldMap[name] = &mappedField{field: name, isRole: true}
|
||||
fieldMap[name] = &fieldMetadata{name: name, isRole: true}
|
||||
}
|
||||
}
|
||||
|
||||
// AddTagNames adds tag names to the field map. This is used to add all tags mapped in the `mappings.yml`
|
||||
// file to the field map, so they can be used in smart playlists.
|
||||
// If a tag name already exists in the field map, it is ignored, so calls to this function are idempotent.
|
||||
// configuration file.
|
||||
func AddTagNames(tagNames []string) {
|
||||
for _, name := range tagNames {
|
||||
name := strings.ToLower(name)
|
||||
for _, tagName := range tagNames {
|
||||
name := strings.ToLower(tagName)
|
||||
if _, ok := fieldMap[name]; ok {
|
||||
continue
|
||||
}
|
||||
@@ -285,20 +138,19 @@ func AddTagNames(tagNames []string) {
|
||||
}
|
||||
}
|
||||
if _, ok := fieldMap[name]; !ok {
|
||||
fieldMap[name] = &mappedField{field: name, isTag: true}
|
||||
fieldMap[name] = &fieldMetadata{name: name, isTag: true}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// AddNumericTags marks the given tag names as numeric so they can be cast
|
||||
// when used in comparisons or sorting.
|
||||
// AddNumericTags adds tags that should be treated as numbers.
|
||||
func AddNumericTags(tagNames []string) {
|
||||
for _, name := range tagNames {
|
||||
name := strings.ToLower(name)
|
||||
for _, tagName := range tagNames {
|
||||
name := strings.ToLower(tagName)
|
||||
if fm, ok := fieldMap[name]; ok {
|
||||
fm.numeric = true
|
||||
} else {
|
||||
fieldMap[name] = &mappedField{field: name, isTag: true, numeric: true}
|
||||
fieldMap[name] = &fieldMetadata{name: name, isTag: true, numeric: true}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,11 +6,58 @@ import (
|
||||
)
|
||||
|
||||
var _ = Describe("fields", func() {
|
||||
Describe("mapFields", func() {
|
||||
It("ignores random fields", func() {
|
||||
m := map[string]any{"random": "123"}
|
||||
m = mapFields(m)
|
||||
gomega.Expect(m).To(gomega.BeEmpty())
|
||||
Describe("LookupField", func() {
|
||||
It("finds built-in fields case-insensitively", func() {
|
||||
field, ok := LookupField("Title")
|
||||
|
||||
gomega.Expect(ok).To(gomega.BeTrue())
|
||||
gomega.Expect(field).To(gomega.Equal(FieldInfo{Name: "title"}))
|
||||
})
|
||||
|
||||
It("resolves aliases to their semantic field name", func() {
|
||||
field, ok := LookupField("albumtype")
|
||||
|
||||
gomega.Expect(ok).To(gomega.BeTrue())
|
||||
gomega.Expect(field.Name).To(gomega.Equal("releasetype"))
|
||||
gomega.Expect(field.IsTag).To(gomega.BeTrue())
|
||||
})
|
||||
|
||||
It("finds special fields", func() {
|
||||
field, ok := LookupField("value")
|
||||
|
||||
gomega.Expect(ok).To(gomega.BeTrue())
|
||||
gomega.Expect(field.Name).To(gomega.Equal("value"))
|
||||
})
|
||||
|
||||
It("finds registered tag names", func() {
|
||||
AddTagNames([]string{"task3_mood"})
|
||||
|
||||
field, ok := LookupField("task3_mood")
|
||||
|
||||
gomega.Expect(ok).To(gomega.BeTrue())
|
||||
gomega.Expect(field.Name).To(gomega.Equal("task3_mood"))
|
||||
gomega.Expect(field.IsTag).To(gomega.BeTrue())
|
||||
})
|
||||
|
||||
It("marks registered numeric tags", func() {
|
||||
AddTagNames([]string{"task3_score"})
|
||||
AddNumericTags([]string{"task3_score"})
|
||||
|
||||
field, ok := LookupField("task3_score")
|
||||
|
||||
gomega.Expect(ok).To(gomega.BeTrue())
|
||||
gomega.Expect(field.IsTag).To(gomega.BeTrue())
|
||||
gomega.Expect(field.Numeric).To(gomega.BeTrue())
|
||||
})
|
||||
|
||||
It("finds registered roles", func() {
|
||||
AddRoles([]string{"task3_producer"})
|
||||
|
||||
field, ok := LookupField("task3_producer")
|
||||
|
||||
gomega.Expect(ok).To(gomega.BeTrue())
|
||||
gomega.Expect(field.Name).To(gomega.Equal("task3_producer"))
|
||||
gomega.Expect(field.IsRole).To(gomega.BeTrue())
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
+25
-194
@@ -1,23 +1,13 @@
|
||||
package criteria
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/Masterminds/squirrel"
|
||||
)
|
||||
import "time"
|
||||
|
||||
type (
|
||||
All squirrel.And
|
||||
All []Expression
|
||||
And = All
|
||||
)
|
||||
|
||||
func (all All) ToSql() (sql string, args []any, err error) {
|
||||
return squirrel.And(all).ToSql()
|
||||
}
|
||||
func (All) criteriaExpression() {}
|
||||
|
||||
func (all All) MarshalJSON() ([]byte, error) {
|
||||
return marshalConjunction("all", all)
|
||||
@@ -28,13 +18,11 @@ func (all All) ChildPlaylistIds() (ids []string) {
|
||||
}
|
||||
|
||||
type (
|
||||
Any squirrel.Or
|
||||
Any []Expression
|
||||
Or = Any
|
||||
)
|
||||
|
||||
func (any Any) ToSql() (sql string, args []any, err error) {
|
||||
return squirrel.Or(any).ToSql()
|
||||
}
|
||||
func (Any) criteriaExpression() {}
|
||||
|
||||
func (any Any) MarshalJSON() ([]byte, error) {
|
||||
return marshalConjunction("any", any)
|
||||
@@ -44,70 +32,42 @@ func (any Any) ChildPlaylistIds() (ids []string) {
|
||||
return extractPlaylistIds(any)
|
||||
}
|
||||
|
||||
type Is squirrel.Eq
|
||||
type Is map[string]any
|
||||
type Eq = Is
|
||||
|
||||
func (is Is) ToSql() (sql string, args []any, err error) {
|
||||
if isRoleExpr(is) {
|
||||
return mapRoleExpr(is, false).ToSql()
|
||||
}
|
||||
if isTagExpr(is) {
|
||||
return mapTagExpr(is, false).ToSql()
|
||||
}
|
||||
return squirrel.Eq(mapFields(is)).ToSql()
|
||||
}
|
||||
func (Is) criteriaExpression() {}
|
||||
|
||||
func (is Is) MarshalJSON() ([]byte, error) {
|
||||
return marshalExpression("is", is)
|
||||
}
|
||||
|
||||
type IsNot squirrel.NotEq
|
||||
type IsNot map[string]any
|
||||
|
||||
func (in IsNot) ToSql() (sql string, args []any, err error) {
|
||||
if isRoleExpr(in) {
|
||||
return mapRoleExpr(squirrel.Eq(in), true).ToSql()
|
||||
}
|
||||
if isTagExpr(in) {
|
||||
return mapTagExpr(squirrel.Eq(in), true).ToSql()
|
||||
}
|
||||
return squirrel.NotEq(mapFields(in)).ToSql()
|
||||
}
|
||||
func (IsNot) criteriaExpression() {}
|
||||
|
||||
func (in IsNot) MarshalJSON() ([]byte, error) {
|
||||
return marshalExpression("isNot", in)
|
||||
}
|
||||
|
||||
type Gt squirrel.Gt
|
||||
type Gt map[string]any
|
||||
|
||||
func (gt Gt) ToSql() (sql string, args []any, err error) {
|
||||
if isTagExpr(gt) {
|
||||
return mapTagExpr(gt, false).ToSql()
|
||||
}
|
||||
return squirrel.Gt(mapFields(gt)).ToSql()
|
||||
}
|
||||
func (Gt) criteriaExpression() {}
|
||||
|
||||
func (gt Gt) MarshalJSON() ([]byte, error) {
|
||||
return marshalExpression("gt", gt)
|
||||
}
|
||||
|
||||
type Lt squirrel.Lt
|
||||
type Lt map[string]any
|
||||
|
||||
func (lt Lt) ToSql() (sql string, args []any, err error) {
|
||||
if isTagExpr(lt) {
|
||||
return mapTagExpr(squirrel.Lt(lt), false).ToSql()
|
||||
}
|
||||
return squirrel.Lt(mapFields(lt)).ToSql()
|
||||
}
|
||||
func (Lt) criteriaExpression() {}
|
||||
|
||||
func (lt Lt) MarshalJSON() ([]byte, error) {
|
||||
return marshalExpression("lt", lt)
|
||||
}
|
||||
|
||||
type Before squirrel.Lt
|
||||
type Before map[string]any
|
||||
|
||||
func (bf Before) ToSql() (sql string, args []any, err error) {
|
||||
return Lt(bf).ToSql()
|
||||
}
|
||||
func (Before) criteriaExpression() {}
|
||||
|
||||
func (bf Before) MarshalJSON() ([]byte, error) {
|
||||
return marshalExpression("before", bf)
|
||||
@@ -115,9 +75,7 @@ func (bf Before) MarshalJSON() ([]byte, error) {
|
||||
|
||||
type After Gt
|
||||
|
||||
func (af After) ToSql() (sql string, args []any, err error) {
|
||||
return Gt(af).ToSql()
|
||||
}
|
||||
func (After) criteriaExpression() {}
|
||||
|
||||
func (af After) MarshalJSON() ([]byte, error) {
|
||||
return marshalExpression("after", af)
|
||||
@@ -125,19 +83,7 @@ func (af After) MarshalJSON() ([]byte, error) {
|
||||
|
||||
type Contains map[string]any
|
||||
|
||||
func (ct Contains) ToSql() (sql string, args []any, err error) {
|
||||
lk := squirrel.Like{}
|
||||
for f, v := range mapFields(ct) {
|
||||
lk[f] = fmt.Sprintf("%%%s%%", v)
|
||||
}
|
||||
if isRoleExpr(ct) {
|
||||
return mapRoleExpr(lk, false).ToSql()
|
||||
}
|
||||
if isTagExpr(ct) {
|
||||
return mapTagExpr(lk, false).ToSql()
|
||||
}
|
||||
return lk.ToSql()
|
||||
}
|
||||
func (Contains) criteriaExpression() {}
|
||||
|
||||
func (ct Contains) MarshalJSON() ([]byte, error) {
|
||||
return marshalExpression("contains", ct)
|
||||
@@ -145,19 +91,7 @@ func (ct Contains) MarshalJSON() ([]byte, error) {
|
||||
|
||||
type NotContains map[string]any
|
||||
|
||||
func (nct NotContains) ToSql() (sql string, args []any, err error) {
|
||||
lk := squirrel.NotLike{}
|
||||
for f, v := range mapFields(nct) {
|
||||
lk[f] = fmt.Sprintf("%%%s%%", v)
|
||||
}
|
||||
if isRoleExpr(nct) {
|
||||
return mapRoleExpr(squirrel.Like(lk), true).ToSql()
|
||||
}
|
||||
if isTagExpr(nct) {
|
||||
return mapTagExpr(squirrel.Like(lk), true).ToSql()
|
||||
}
|
||||
return lk.ToSql()
|
||||
}
|
||||
func (NotContains) criteriaExpression() {}
|
||||
|
||||
func (nct NotContains) MarshalJSON() ([]byte, error) {
|
||||
return marshalExpression("notContains", nct)
|
||||
@@ -165,19 +99,7 @@ func (nct NotContains) MarshalJSON() ([]byte, error) {
|
||||
|
||||
type StartsWith map[string]any
|
||||
|
||||
func (sw StartsWith) ToSql() (sql string, args []any, err error) {
|
||||
lk := squirrel.Like{}
|
||||
for f, v := range mapFields(sw) {
|
||||
lk[f] = fmt.Sprintf("%s%%", v)
|
||||
}
|
||||
if isRoleExpr(sw) {
|
||||
return mapRoleExpr(lk, false).ToSql()
|
||||
}
|
||||
if isTagExpr(sw) {
|
||||
return mapTagExpr(lk, false).ToSql()
|
||||
}
|
||||
return lk.ToSql()
|
||||
}
|
||||
func (StartsWith) criteriaExpression() {}
|
||||
|
||||
func (sw StartsWith) MarshalJSON() ([]byte, error) {
|
||||
return marshalExpression("startsWith", sw)
|
||||
@@ -185,19 +107,7 @@ func (sw StartsWith) MarshalJSON() ([]byte, error) {
|
||||
|
||||
type EndsWith map[string]any
|
||||
|
||||
func (sw EndsWith) ToSql() (sql string, args []any, err error) {
|
||||
lk := squirrel.Like{}
|
||||
for f, v := range mapFields(sw) {
|
||||
lk[f] = fmt.Sprintf("%%%s", v)
|
||||
}
|
||||
if isRoleExpr(sw) {
|
||||
return mapRoleExpr(lk, false).ToSql()
|
||||
}
|
||||
if isTagExpr(sw) {
|
||||
return mapTagExpr(lk, false).ToSql()
|
||||
}
|
||||
return lk.ToSql()
|
||||
}
|
||||
func (EndsWith) criteriaExpression() {}
|
||||
|
||||
func (sw EndsWith) MarshalJSON() ([]byte, error) {
|
||||
return marshalExpression("endsWith", sw)
|
||||
@@ -205,20 +115,7 @@ func (sw EndsWith) MarshalJSON() ([]byte, error) {
|
||||
|
||||
type InTheRange map[string]any
|
||||
|
||||
func (itr InTheRange) ToSql() (sql string, args []any, err error) {
|
||||
and := squirrel.And{}
|
||||
for f, v := range mapFields(itr) {
|
||||
s := reflect.ValueOf(v)
|
||||
if s.Kind() != reflect.Slice || s.Len() != 2 {
|
||||
return "", nil, fmt.Errorf("invalid range for 'in' operator: %s", v)
|
||||
}
|
||||
and = append(and,
|
||||
squirrel.GtOrEq{f: s.Index(0).Interface()},
|
||||
squirrel.LtOrEq{f: s.Index(1).Interface()},
|
||||
)
|
||||
}
|
||||
return and.ToSql()
|
||||
}
|
||||
func (InTheRange) criteriaExpression() {}
|
||||
|
||||
func (itr InTheRange) MarshalJSON() ([]byte, error) {
|
||||
return marshalExpression("inTheRange", itr)
|
||||
@@ -226,13 +123,7 @@ func (itr InTheRange) MarshalJSON() ([]byte, error) {
|
||||
|
||||
type InTheLast map[string]any
|
||||
|
||||
func (itl InTheLast) ToSql() (sql string, args []any, err error) {
|
||||
exp, err := inPeriod(itl, false)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
return exp.ToSql()
|
||||
}
|
||||
func (InTheLast) criteriaExpression() {}
|
||||
|
||||
func (itl InTheLast) MarshalJSON() ([]byte, error) {
|
||||
return marshalExpression("inTheLast", itl)
|
||||
@@ -240,50 +131,19 @@ func (itl InTheLast) MarshalJSON() ([]byte, error) {
|
||||
|
||||
type NotInTheLast map[string]any
|
||||
|
||||
func (nitl NotInTheLast) ToSql() (sql string, args []any, err error) {
|
||||
exp, err := inPeriod(nitl, true)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
return exp.ToSql()
|
||||
}
|
||||
func (NotInTheLast) criteriaExpression() {}
|
||||
|
||||
func (nitl NotInTheLast) MarshalJSON() ([]byte, error) {
|
||||
return marshalExpression("notInTheLast", nitl)
|
||||
}
|
||||
|
||||
func inPeriod(m map[string]any, negate bool) (Expression, error) {
|
||||
var field string
|
||||
var value any
|
||||
for f, v := range mapFields(m) {
|
||||
field, value = f, v
|
||||
break
|
||||
}
|
||||
str := fmt.Sprintf("%v", value)
|
||||
v, err := strconv.ParseInt(str, 10, 64)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
firstDate := startOfPeriod(v, time.Now())
|
||||
|
||||
if negate {
|
||||
return Or{
|
||||
squirrel.Lt{field: firstDate},
|
||||
squirrel.Eq{field: nil},
|
||||
}, nil
|
||||
}
|
||||
return squirrel.Gt{field: firstDate}, nil
|
||||
}
|
||||
|
||||
func startOfPeriod(numDays int64, from time.Time) string {
|
||||
return from.Add(time.Duration(-24*numDays) * time.Hour).Format("2006-01-02")
|
||||
}
|
||||
|
||||
type InPlaylist map[string]any
|
||||
|
||||
func (ipl InPlaylist) ToSql() (sql string, args []any, err error) {
|
||||
return inList(ipl, false)
|
||||
}
|
||||
func (InPlaylist) criteriaExpression() {}
|
||||
|
||||
func (ipl InPlaylist) MarshalJSON() ([]byte, error) {
|
||||
return marshalExpression("inPlaylist", ipl)
|
||||
@@ -291,41 +151,12 @@ func (ipl InPlaylist) MarshalJSON() ([]byte, error) {
|
||||
|
||||
type NotInPlaylist map[string]any
|
||||
|
||||
func (ipl NotInPlaylist) ToSql() (sql string, args []any, err error) {
|
||||
return inList(ipl, true)
|
||||
}
|
||||
func (NotInPlaylist) criteriaExpression() {}
|
||||
|
||||
func (ipl NotInPlaylist) MarshalJSON() ([]byte, error) {
|
||||
return marshalExpression("notInPlaylist", ipl)
|
||||
}
|
||||
|
||||
func inList(m map[string]any, negate bool) (sql string, args []any, err error) {
|
||||
var playlistid string
|
||||
var ok bool
|
||||
if playlistid, ok = m["id"].(string); !ok {
|
||||
return "", nil, errors.New("playlist id not given")
|
||||
}
|
||||
|
||||
// Subquery to fetch all media files that are contained in given playlist
|
||||
// Only evaluate playlist if it is public
|
||||
subQuery := squirrel.Select("media_file_id").
|
||||
From("playlist_tracks pl").
|
||||
LeftJoin("playlist on pl.playlist_id = playlist.id").
|
||||
Where(squirrel.And{
|
||||
squirrel.Eq{"pl.playlist_id": playlistid},
|
||||
squirrel.Eq{"playlist.public": 1}})
|
||||
subQText, subQArgs, err := subQuery.PlaceholderFormat(squirrel.Question).ToSql()
|
||||
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
if negate {
|
||||
return "media_file.id NOT IN (" + subQText + ")", subQArgs, nil
|
||||
} else {
|
||||
return "media_file.id IN (" + subQText + ")", subQArgs, nil
|
||||
}
|
||||
}
|
||||
|
||||
func extractPlaylistIds(inputRule any) (ids []string) {
|
||||
var id string
|
||||
var ok bool
|
||||
|
||||
@@ -3,7 +3,6 @@ package criteria_test
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
. "github.com/navidrome/navidrome/model/criteria"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
@@ -17,182 +16,6 @@ var _ = BeforeSuite(func() {
|
||||
})
|
||||
|
||||
var _ = Describe("Operators", func() {
|
||||
rangeStart := time.Date(2021, 10, 01, 0, 0, 0, 0, time.Local)
|
||||
rangeEnd := time.Date(2021, 11, 01, 0, 0, 0, 0, time.Local)
|
||||
|
||||
DescribeTable("ToSQL",
|
||||
func(op Expression, expectedSql string, expectedArgs ...any) {
|
||||
sql, args, err := op.ToSql()
|
||||
gomega.Expect(err).ToNot(gomega.HaveOccurred())
|
||||
gomega.Expect(sql).To(gomega.Equal(expectedSql))
|
||||
gomega.Expect(args).To(gomega.HaveExactElements(expectedArgs...))
|
||||
},
|
||||
Entry("is [string]", Is{"title": "Low Rider"}, "media_file.title = ?", "Low Rider"),
|
||||
Entry("is [bool]", Is{"loved": true}, "COALESCE(annotation.starred, false) = ?", true),
|
||||
Entry("is [numeric]", Is{"library_id": 1}, "media_file.library_id = ?", 1),
|
||||
Entry("is [numeric list]", Is{"library_id": []int{1, 2}}, "media_file.library_id IN (?,?)", 1, 2),
|
||||
Entry("isNot", IsNot{"title": "Low Rider"}, "media_file.title <> ?", "Low Rider"),
|
||||
Entry("isNot [numeric]", IsNot{"library_id": 1}, "media_file.library_id <> ?", 1),
|
||||
Entry("isNot [numeric list]", IsNot{"library_id": []int{1, 2}}, "media_file.library_id NOT IN (?,?)", 1, 2),
|
||||
Entry("gt", Gt{"playCount": 10}, "COALESCE(annotation.play_count, 0) > ?", 10),
|
||||
Entry("lt", Lt{"playCount": 10}, "COALESCE(annotation.play_count, 0) < ?", 10),
|
||||
Entry("contains", Contains{"title": "Low Rider"}, "media_file.title LIKE ?", "%Low Rider%"),
|
||||
Entry("notContains", NotContains{"title": "Low Rider"}, "media_file.title NOT LIKE ?", "%Low Rider%"),
|
||||
Entry("startsWith", StartsWith{"title": "Low Rider"}, "media_file.title LIKE ?", "Low Rider%"),
|
||||
Entry("endsWith", EndsWith{"title": "Low Rider"}, "media_file.title LIKE ?", "%Low Rider"),
|
||||
Entry("inTheRange [number]", InTheRange{"year": []int{1980, 1990}}, "(media_file.year >= ? AND media_file.year <= ?)", 1980, 1990),
|
||||
Entry("inTheRange [date]", InTheRange{"lastPlayed": []time.Time{rangeStart, rangeEnd}}, "(annotation.play_date >= ? AND annotation.play_date <= ?)", rangeStart, rangeEnd),
|
||||
Entry("before", Before{"lastPlayed": rangeStart}, "annotation.play_date < ?", rangeStart),
|
||||
Entry("after", After{"lastPlayed": rangeStart}, "annotation.play_date > ?", rangeStart),
|
||||
|
||||
// InPlaylist and NotInPlaylist are special cases
|
||||
Entry("inPlaylist", InPlaylist{"id": "deadbeef-dead-beef"}, "media_file.id IN "+
|
||||
"(SELECT media_file_id FROM playlist_tracks pl LEFT JOIN playlist on pl.playlist_id = playlist.id WHERE (pl.playlist_id = ? AND playlist.public = ?))", "deadbeef-dead-beef", 1),
|
||||
Entry("notInPlaylist", NotInPlaylist{"id": "deadbeef-dead-beef"}, "media_file.id NOT IN "+
|
||||
"(SELECT media_file_id FROM playlist_tracks pl LEFT JOIN playlist on pl.playlist_id = playlist.id WHERE (pl.playlist_id = ? AND playlist.public = ?))", "deadbeef-dead-beef", 1),
|
||||
|
||||
Entry("inTheLast", InTheLast{"lastPlayed": 30}, "annotation.play_date > ?", StartOfPeriod(30, time.Now())),
|
||||
Entry("notInTheLast", NotInTheLast{"lastPlayed": 30}, "(annotation.play_date < ? OR annotation.play_date IS NULL)", StartOfPeriod(30, time.Now())),
|
||||
|
||||
// Album annotation fields
|
||||
Entry("albumRating", Gt{"albumRating": 3}, "COALESCE(album_annotation.rating, 0) > ?", 3),
|
||||
Entry("albumLoved", Is{"albumLoved": true}, "COALESCE(album_annotation.starred, false) = ?", true),
|
||||
Entry("albumPlayCount", Gt{"albumPlayCount": 5}, "COALESCE(album_annotation.play_count, 0) > ?", 5),
|
||||
Entry("albumLastPlayed", After{"albumLastPlayed": rangeStart}, "album_annotation.play_date > ?", rangeStart),
|
||||
Entry("albumDateLoved", Before{"albumDateLoved": rangeStart}, "album_annotation.starred_at < ?", rangeStart),
|
||||
Entry("albumDateRated", After{"albumDateRated": rangeStart}, "album_annotation.rated_at > ?", rangeStart),
|
||||
Entry("albumLastPlayed inTheLast", InTheLast{"albumLastPlayed": 30}, "album_annotation.play_date > ?", StartOfPeriod(30, time.Now())),
|
||||
Entry("albumLastPlayed notInTheLast", NotInTheLast{"albumLastPlayed": 30}, "(album_annotation.play_date < ? OR album_annotation.play_date IS NULL)", StartOfPeriod(30, time.Now())),
|
||||
|
||||
// Artist annotation fields
|
||||
Entry("artistRating", Gt{"artistRating": 3}, "COALESCE(artist_annotation.rating, 0) > ?", 3),
|
||||
Entry("artistLoved", Is{"artistLoved": true}, "COALESCE(artist_annotation.starred, false) = ?", true),
|
||||
Entry("artistPlayCount", Gt{"artistPlayCount": 5}, "COALESCE(artist_annotation.play_count, 0) > ?", 5),
|
||||
Entry("artistLastPlayed", After{"artistLastPlayed": rangeStart}, "artist_annotation.play_date > ?", rangeStart),
|
||||
Entry("artistDateLoved", Before{"artistDateLoved": rangeStart}, "artist_annotation.starred_at < ?", rangeStart),
|
||||
Entry("artistDateRated", After{"artistDateRated": rangeStart}, "artist_annotation.rated_at > ?", rangeStart),
|
||||
Entry("artistLastPlayed inTheLast", InTheLast{"artistLastPlayed": 30}, "artist_annotation.play_date > ?", StartOfPeriod(30, time.Now())),
|
||||
Entry("artistLastPlayed notInTheLast", NotInTheLast{"artistLastPlayed": 30}, "(artist_annotation.play_date < ? OR artist_annotation.play_date IS NULL)", StartOfPeriod(30, time.Now())),
|
||||
|
||||
// Tag tests
|
||||
Entry("tag is [string]", Is{"genre": "Rock"}, "exists (select 1 from json_tree(media_file.tags, '$.genre') where key='value' and value = ?)", "Rock"),
|
||||
Entry("tag isNot [string]", IsNot{"genre": "Rock"}, "not exists (select 1 from json_tree(media_file.tags, '$.genre') where key='value' and value = ?)", "Rock"),
|
||||
Entry("tag gt", Gt{"genre": "A"}, "exists (select 1 from json_tree(media_file.tags, '$.genre') where key='value' and value > ?)", "A"),
|
||||
Entry("tag lt", Lt{"genre": "Z"}, "exists (select 1 from json_tree(media_file.tags, '$.genre') where key='value' and value < ?)", "Z"),
|
||||
Entry("tag contains", Contains{"genre": "Rock"}, "exists (select 1 from json_tree(media_file.tags, '$.genre') where key='value' and value LIKE ?)", "%Rock%"),
|
||||
Entry("tag not contains", NotContains{"genre": "Rock"}, "not exists (select 1 from json_tree(media_file.tags, '$.genre') where key='value' and value LIKE ?)", "%Rock%"),
|
||||
Entry("tag startsWith", StartsWith{"genre": "Soft"}, "exists (select 1 from json_tree(media_file.tags, '$.genre') where key='value' and value LIKE ?)", "Soft%"),
|
||||
Entry("tag endsWith", EndsWith{"genre": "Rock"}, "exists (select 1 from json_tree(media_file.tags, '$.genre') where key='value' and value LIKE ?)", "%Rock"),
|
||||
|
||||
// Artist roles tests
|
||||
Entry("role is [string]", Is{"artist": "u2"}, "exists (select 1 from json_tree(media_file.participants, '$.artist') where key='name' and value = ?)", "u2"),
|
||||
Entry("role isNot [string]", IsNot{"artist": "u2"}, "not exists (select 1 from json_tree(media_file.participants, '$.artist') where key='name' and value = ?)", "u2"),
|
||||
Entry("role contains [string]", Contains{"artist": "u2"}, "exists (select 1 from json_tree(media_file.participants, '$.artist') where key='name' and value LIKE ?)", "%u2%"),
|
||||
Entry("role not contains [string]", NotContains{"artist": "u2"}, "not exists (select 1 from json_tree(media_file.participants, '$.artist') where key='name' and value LIKE ?)", "%u2%"),
|
||||
Entry("role startsWith [string]", StartsWith{"composer": "John"}, "exists (select 1 from json_tree(media_file.participants, '$.composer') where key='name' and value LIKE ?)", "John%"),
|
||||
Entry("role endsWith [string]", EndsWith{"composer": "Lennon"}, "exists (select 1 from json_tree(media_file.participants, '$.composer') where key='name' and value LIKE ?)", "%Lennon"),
|
||||
)
|
||||
|
||||
// TODO Validate operators that are not valid for each field type.
|
||||
XDescribeTable("ToSQL - Invalid Operators",
|
||||
func(op Expression, expectedError string) {
|
||||
_, _, err := op.ToSql()
|
||||
gomega.Expect(err).To(gomega.MatchError(expectedError))
|
||||
},
|
||||
Entry("numeric tag contains", Contains{"rate": 5}, "numeric tag 'rate' cannot be used with Contains operator"),
|
||||
)
|
||||
|
||||
Describe("Custom Tags", func() {
|
||||
It("generates valid SQL", func() {
|
||||
AddTagNames([]string{"mood"})
|
||||
op := EndsWith{"mood": "Soft"}
|
||||
sql, args, err := op.ToSql()
|
||||
gomega.Expect(err).ToNot(gomega.HaveOccurred())
|
||||
gomega.Expect(sql).To(gomega.Equal("exists (select 1 from json_tree(media_file.tags, '$.mood') where key='value' and value LIKE ?)"))
|
||||
gomega.Expect(args).To(gomega.HaveExactElements("%Soft"))
|
||||
})
|
||||
It("casts numeric comparisons", func() {
|
||||
AddNumericTags([]string{"rate"})
|
||||
op := Lt{"rate": 6}
|
||||
sql, args, err := op.ToSql()
|
||||
gomega.Expect(err).ToNot(gomega.HaveOccurred())
|
||||
gomega.Expect(sql).To(gomega.Equal("exists (select 1 from json_tree(media_file.tags, '$.rate') where key='value' and CAST(value AS REAL) < ?)"))
|
||||
gomega.Expect(args).To(gomega.HaveExactElements(6))
|
||||
})
|
||||
It("skips unknown tag names", func() {
|
||||
op := EndsWith{"unknown": "value"}
|
||||
sql, args, _ := op.ToSql()
|
||||
gomega.Expect(sql).To(gomega.BeEmpty())
|
||||
gomega.Expect(args).To(gomega.BeEmpty())
|
||||
})
|
||||
It("supports releasetype as multi-valued tag", func() {
|
||||
AddTagNames([]string{"releasetype"})
|
||||
op := Contains{"releasetype": "soundtrack"}
|
||||
sql, args, err := op.ToSql()
|
||||
gomega.Expect(err).ToNot(gomega.HaveOccurred())
|
||||
gomega.Expect(sql).To(gomega.Equal("exists (select 1 from json_tree(media_file.tags, '$.releasetype') where key='value' and value LIKE ?)"))
|
||||
gomega.Expect(args).To(gomega.HaveExactElements("%soundtrack%"))
|
||||
})
|
||||
It("supports albumtype as alias for releasetype", func() {
|
||||
AddTagNames([]string{"releasetype"})
|
||||
op := Contains{"albumtype": "live"}
|
||||
sql, args, err := op.ToSql()
|
||||
gomega.Expect(err).ToNot(gomega.HaveOccurred())
|
||||
gomega.Expect(sql).To(gomega.Equal("exists (select 1 from json_tree(media_file.tags, '$.releasetype') where key='value' and value LIKE ?)"))
|
||||
gomega.Expect(args).To(gomega.HaveExactElements("%live%"))
|
||||
})
|
||||
It("supports albumtype alias with Is operator", func() {
|
||||
AddTagNames([]string{"releasetype"})
|
||||
op := Is{"albumtype": "album"}
|
||||
sql, args, err := op.ToSql()
|
||||
gomega.Expect(err).ToNot(gomega.HaveOccurred())
|
||||
// Should query $.releasetype, not $.albumtype
|
||||
gomega.Expect(sql).To(gomega.Equal("exists (select 1 from json_tree(media_file.tags, '$.releasetype') where key='value' and value = ?)"))
|
||||
gomega.Expect(args).To(gomega.HaveExactElements("album"))
|
||||
})
|
||||
It("supports albumtype alias with IsNot operator", func() {
|
||||
AddTagNames([]string{"releasetype"})
|
||||
op := IsNot{"albumtype": "compilation"}
|
||||
sql, args, err := op.ToSql()
|
||||
gomega.Expect(err).ToNot(gomega.HaveOccurred())
|
||||
// Should query $.releasetype, not $.albumtype
|
||||
gomega.Expect(sql).To(gomega.Equal("not exists (select 1 from json_tree(media_file.tags, '$.releasetype') where key='value' and value = ?)"))
|
||||
gomega.Expect(args).To(gomega.HaveExactElements("compilation"))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Custom Roles", func() {
|
||||
It("generates valid SQL", func() {
|
||||
AddRoles([]string{"producer"})
|
||||
op := EndsWith{"producer": "Eno"}
|
||||
sql, args, err := op.ToSql()
|
||||
gomega.Expect(err).ToNot(gomega.HaveOccurred())
|
||||
gomega.Expect(sql).To(gomega.Equal("exists (select 1 from json_tree(media_file.participants, '$.producer') where key='name' and value LIKE ?)"))
|
||||
gomega.Expect(args).To(gomega.HaveExactElements("%Eno"))
|
||||
})
|
||||
It("skips unknown roles", func() {
|
||||
op := Contains{"groupie": "Penny Lane"}
|
||||
sql, args, _ := op.ToSql()
|
||||
gomega.Expect(sql).To(gomega.BeEmpty())
|
||||
gomega.Expect(args).To(gomega.BeEmpty())
|
||||
})
|
||||
})
|
||||
|
||||
DescribeTable("ToSql idempotency",
|
||||
func(expr Expression) {
|
||||
sql1, args1, err1 := expr.ToSql()
|
||||
sql2, args2, err2 := expr.ToSql()
|
||||
|
||||
gomega.Expect(err1).ToNot(gomega.HaveOccurred())
|
||||
gomega.Expect(err2).ToNot(gomega.HaveOccurred())
|
||||
gomega.Expect(sql2).To(gomega.Equal(sql1))
|
||||
gomega.Expect(args2).To(gomega.Equal(args1))
|
||||
},
|
||||
Entry("tag expression", Is{"genre": "Rock"}),
|
||||
Entry("role expression", Contains{"artist": "Beatles"}),
|
||||
Entry("nested criteria", Criteria{Expression: All{Is{"genre": "Rock"}, Contains{"artist": "Beatles"}}}),
|
||||
)
|
||||
|
||||
DescribeTable("JSON Marshaling",
|
||||
func(op Expression, jsonString string) {
|
||||
obj := And{op}
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
package criteria
|
||||
|
||||
import "fmt"
|
||||
|
||||
type Visitor func(Expression) error
|
||||
|
||||
func Walk(expr Expression, visit Visitor) error {
|
||||
if expr == nil {
|
||||
return nil
|
||||
}
|
||||
if err := visit(expr); err != nil {
|
||||
return err
|
||||
}
|
||||
switch e := expr.(type) {
|
||||
case All:
|
||||
for _, child := range e {
|
||||
if err := Walk(child, visit); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
case Any:
|
||||
for _, child := range e {
|
||||
if err := Walk(child, visit); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
case Is, IsNot, Gt, Lt, Before, After, Contains, NotContains, StartsWith, EndsWith, InTheRange, InTheLast, NotInTheLast, InPlaylist, NotInPlaylist:
|
||||
return nil
|
||||
default:
|
||||
return fmt.Errorf("unknown criteria expression type %T", expr)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Fields returns field values for leaf expressions only.
|
||||
// Use Walk to traverse All and Any expressions before calling Fields.
|
||||
func Fields(expr Expression) map[string]any {
|
||||
switch e := expr.(type) {
|
||||
case Is:
|
||||
return map[string]any(e)
|
||||
case IsNot:
|
||||
return map[string]any(e)
|
||||
case Gt:
|
||||
return map[string]any(e)
|
||||
case Lt:
|
||||
return map[string]any(e)
|
||||
case Before:
|
||||
return map[string]any(e)
|
||||
case After:
|
||||
return map[string]any(Gt(e))
|
||||
case Contains:
|
||||
return map[string]any(e)
|
||||
case NotContains:
|
||||
return map[string]any(e)
|
||||
case StartsWith:
|
||||
return map[string]any(e)
|
||||
case EndsWith:
|
||||
return map[string]any(e)
|
||||
case InTheRange:
|
||||
return map[string]any(e)
|
||||
case InTheLast:
|
||||
return map[string]any(e)
|
||||
case NotInTheLast:
|
||||
return map[string]any(e)
|
||||
case InPlaylist:
|
||||
return map[string]any(e)
|
||||
case NotInPlaylist:
|
||||
return map[string]any(e)
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package criteria
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
"github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
type unknownExpression struct{}
|
||||
|
||||
func (unknownExpression) criteriaExpression() {}
|
||||
|
||||
var _ = Describe("Walk", func() {
|
||||
It("visits the expression tree depth-first", func() {
|
||||
expr := All{
|
||||
Contains{"title": "love"},
|
||||
Any{
|
||||
Is{"album": "best of"},
|
||||
Gt{"rating": 3},
|
||||
},
|
||||
}
|
||||
|
||||
var visited []string
|
||||
err := Walk(expr, func(expr Expression) error {
|
||||
visited = append(visited, fmt.Sprintf("%T", expr))
|
||||
return nil
|
||||
})
|
||||
|
||||
gomega.Expect(err).ToNot(gomega.HaveOccurred())
|
||||
gomega.Expect(visited).To(gomega.Equal([]string{
|
||||
"criteria.All",
|
||||
"criteria.Contains",
|
||||
"criteria.Any",
|
||||
"criteria.Is",
|
||||
"criteria.Gt",
|
||||
}))
|
||||
})
|
||||
|
||||
It("stops when the visitor returns an error", func() {
|
||||
expectedErr := fmt.Errorf("stop")
|
||||
|
||||
err := Walk(All{Contains{"title": "love"}}, func(Expression) error {
|
||||
return expectedErr
|
||||
})
|
||||
|
||||
gomega.Expect(err).To(gomega.MatchError(expectedErr))
|
||||
})
|
||||
|
||||
It("returns fields for leaf expressions", func() {
|
||||
gomega.Expect(Fields(Contains{"title": "love"})).To(gomega.Equal(map[string]any{"title": "love"}))
|
||||
gomega.Expect(Fields(After{"date": "2020-01-01"})).To(gomega.Equal(map[string]any{"date": "2020-01-01"}))
|
||||
})
|
||||
|
||||
It("returns nil fields for group expressions", func() {
|
||||
gomega.Expect(Fields(All{Contains{"title": "love"}})).To(gomega.BeNil())
|
||||
})
|
||||
|
||||
It("returns an error for unknown expression types", func() {
|
||||
err := Walk(unknownExpression{}, func(Expression) error { return nil })
|
||||
|
||||
gomega.Expect(err).To(gomega.MatchError("unknown criteria expression type criteria.unknownExpression"))
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,487 @@
|
||||
package persistence
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
squirrel "github.com/Masterminds/squirrel"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model/criteria"
|
||||
)
|
||||
|
||||
type smartPlaylistJoinType int
|
||||
|
||||
const (
|
||||
smartPlaylistJoinNone smartPlaylistJoinType = 0
|
||||
smartPlaylistJoinAlbumAnnotation smartPlaylistJoinType = 1 << iota
|
||||
smartPlaylistJoinArtistAnnotation
|
||||
)
|
||||
|
||||
func (j smartPlaylistJoinType) has(other smartPlaylistJoinType) bool {
|
||||
return j&other != 0
|
||||
}
|
||||
|
||||
type smartPlaylistField struct {
|
||||
expr string
|
||||
order string
|
||||
joinType smartPlaylistJoinType
|
||||
}
|
||||
|
||||
type smartPlaylistCriteria struct {
|
||||
criteria criteria.Criteria
|
||||
}
|
||||
|
||||
func newSmartPlaylistCriteria(c criteria.Criteria) smartPlaylistCriteria {
|
||||
return smartPlaylistCriteria{criteria: c}
|
||||
}
|
||||
|
||||
var smartPlaylistFields = map[string]smartPlaylistField{
|
||||
"title": {expr: "media_file.title"},
|
||||
"album": {expr: "media_file.album"},
|
||||
"hascoverart": {expr: "media_file.has_cover_art"},
|
||||
"tracknumber": {expr: "media_file.track_number"},
|
||||
"discnumber": {expr: "media_file.disc_number"},
|
||||
"year": {expr: "media_file.year"},
|
||||
"date": {expr: "media_file.date"},
|
||||
"originalyear": {expr: "media_file.original_year"},
|
||||
"originaldate": {expr: "media_file.original_date"},
|
||||
"releaseyear": {expr: "media_file.release_year"},
|
||||
"releasedate": {expr: "media_file.release_date"},
|
||||
"size": {expr: "media_file.size"},
|
||||
"compilation": {expr: "media_file.compilation"},
|
||||
"missing": {expr: "media_file.missing"},
|
||||
"explicitstatus": {expr: "media_file.explicit_status"},
|
||||
"dateadded": {expr: "media_file.created_at"},
|
||||
"datemodified": {expr: "media_file.updated_at"},
|
||||
"discsubtitle": {expr: "media_file.disc_subtitle"},
|
||||
"comment": {expr: "media_file.comment"},
|
||||
"lyrics": {expr: "media_file.lyrics"},
|
||||
"sorttitle": {expr: "media_file.sort_title"},
|
||||
"sortalbum": {expr: "media_file.sort_album_name"},
|
||||
"sortartist": {expr: "media_file.sort_artist_name"},
|
||||
"sortalbumartist": {expr: "media_file.sort_album_artist_name"},
|
||||
"albumcomment": {expr: "media_file.mbz_album_comment"},
|
||||
"catalognumber": {expr: "media_file.catalog_num"},
|
||||
"filepath": {expr: "media_file.path"},
|
||||
"filetype": {expr: "media_file.suffix"},
|
||||
"codec": {expr: "media_file.codec"},
|
||||
"duration": {expr: "media_file.duration"},
|
||||
"bitrate": {expr: "media_file.bit_rate"},
|
||||
"bitdepth": {expr: "media_file.bit_depth"},
|
||||
"samplerate": {expr: "media_file.sample_rate"},
|
||||
"bpm": {expr: "media_file.bpm"},
|
||||
"channels": {expr: "media_file.channels"},
|
||||
"loved": {expr: "COALESCE(annotation.starred, false)"},
|
||||
"dateloved": {expr: "annotation.starred_at"},
|
||||
"lastplayed": {expr: "annotation.play_date"},
|
||||
"daterated": {expr: "annotation.rated_at"},
|
||||
"playcount": {expr: "COALESCE(annotation.play_count, 0)"},
|
||||
"rating": {expr: "COALESCE(annotation.rating, 0)"},
|
||||
"averagerating": {expr: "media_file.average_rating"},
|
||||
"albumrating": {expr: "COALESCE(album_annotation.rating, 0)", joinType: smartPlaylistJoinAlbumAnnotation},
|
||||
"albumloved": {expr: "COALESCE(album_annotation.starred, false)", joinType: smartPlaylistJoinAlbumAnnotation},
|
||||
"albumplaycount": {expr: "COALESCE(album_annotation.play_count, 0)", joinType: smartPlaylistJoinAlbumAnnotation},
|
||||
"albumlastplayed": {expr: "album_annotation.play_date", joinType: smartPlaylistJoinAlbumAnnotation},
|
||||
"albumdateloved": {expr: "album_annotation.starred_at", joinType: smartPlaylistJoinAlbumAnnotation},
|
||||
"albumdaterated": {expr: "album_annotation.rated_at", joinType: smartPlaylistJoinAlbumAnnotation},
|
||||
"artistrating": {expr: "COALESCE(artist_annotation.rating, 0)", joinType: smartPlaylistJoinArtistAnnotation},
|
||||
"artistloved": {expr: "COALESCE(artist_annotation.starred, false)", joinType: smartPlaylistJoinArtistAnnotation},
|
||||
"artistplaycount": {expr: "COALESCE(artist_annotation.play_count, 0)", joinType: smartPlaylistJoinArtistAnnotation},
|
||||
"artistlastplayed": {expr: "artist_annotation.play_date", joinType: smartPlaylistJoinArtistAnnotation},
|
||||
"artistdateloved": {expr: "artist_annotation.starred_at", joinType: smartPlaylistJoinArtistAnnotation},
|
||||
"artistdaterated": {expr: "artist_annotation.rated_at", joinType: smartPlaylistJoinArtistAnnotation},
|
||||
"mbz_album_id": {expr: "media_file.mbz_album_id"},
|
||||
"mbz_album_artist_id": {expr: "media_file.mbz_album_artist_id"},
|
||||
"mbz_artist_id": {expr: "media_file.mbz_artist_id"},
|
||||
"mbz_recording_id": {expr: "media_file.mbz_recording_id"},
|
||||
"mbz_release_track_id": {expr: "media_file.mbz_release_track_id"},
|
||||
"mbz_release_group_id": {expr: "media_file.mbz_release_group_id"},
|
||||
"library_id": {expr: "media_file.library_id"},
|
||||
"random": {order: "random()"},
|
||||
"value": {expr: "value"},
|
||||
}
|
||||
|
||||
func (c smartPlaylistCriteria) Where() (squirrel.Sqlizer, error) {
|
||||
if c.criteria.Expression == nil {
|
||||
return squirrel.Expr("1 = 1"), nil
|
||||
}
|
||||
return exprSQL(c.criteria.Expression)
|
||||
}
|
||||
|
||||
func exprSQL(expr criteria.Expression) (squirrel.Sqlizer, error) {
|
||||
switch e := expr.(type) {
|
||||
case criteria.All:
|
||||
and := squirrel.And{}
|
||||
for _, child := range e {
|
||||
cond, err := exprSQL(child)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
and = append(and, cond)
|
||||
}
|
||||
return and, nil
|
||||
case criteria.Any:
|
||||
or := squirrel.Or{}
|
||||
for _, child := range e {
|
||||
cond, err := exprSQL(child)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
or = append(or, cond)
|
||||
}
|
||||
return or, nil
|
||||
case criteria.Is:
|
||||
return mapExpr(e, func(fields map[string]any) squirrel.Sqlizer {
|
||||
return squirrel.Eq(fields)
|
||||
}, false)
|
||||
case criteria.IsNot:
|
||||
return isNotExpr(e)
|
||||
case criteria.Gt:
|
||||
return mapExpr(e, func(fields map[string]any) squirrel.Sqlizer {
|
||||
return squirrel.Gt(fields)
|
||||
}, false)
|
||||
case criteria.Lt:
|
||||
return mapExpr(e, func(fields map[string]any) squirrel.Sqlizer {
|
||||
return squirrel.Lt(fields)
|
||||
}, false)
|
||||
case criteria.Before:
|
||||
return mapExpr(e, func(fields map[string]any) squirrel.Sqlizer {
|
||||
return squirrel.Lt(fields)
|
||||
}, false)
|
||||
case criteria.After:
|
||||
return mapExpr(e, func(fields map[string]any) squirrel.Sqlizer {
|
||||
return squirrel.Gt(fields)
|
||||
}, false)
|
||||
case criteria.Contains:
|
||||
return likeExpr(e, "%%%v%%", false)
|
||||
case criteria.NotContains:
|
||||
return likeExpr(e, "%%%v%%", true)
|
||||
case criteria.StartsWith:
|
||||
return likeExpr(e, "%v%%", false)
|
||||
case criteria.EndsWith:
|
||||
return likeExpr(e, "%%%v", false)
|
||||
case criteria.InTheRange:
|
||||
return rangeExpr(e)
|
||||
case criteria.InTheLast:
|
||||
return periodExpr(e, false)
|
||||
case criteria.NotInTheLast:
|
||||
return periodExpr(e, true)
|
||||
case criteria.InPlaylist:
|
||||
return inList(e, false)
|
||||
case criteria.NotInPlaylist:
|
||||
return inList(e, true)
|
||||
default:
|
||||
return nil, fmt.Errorf("unknown criteria expression type %T", expr)
|
||||
}
|
||||
}
|
||||
|
||||
func isNotExpr[T ~map[string]any](values T) (squirrel.Sqlizer, error) {
|
||||
if _, value, info, ok := singleField(values); ok && (info.IsTag || info.IsRole) {
|
||||
return jsonExpr(info, squirrel.Eq{"value": value}, true), nil
|
||||
}
|
||||
fields, err := sqlFields(values)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return squirrel.NotEq(fields), nil
|
||||
}
|
||||
|
||||
func mapExpr[T ~map[string]any](values T, makeCond func(map[string]any) squirrel.Sqlizer, negateJSON bool) (squirrel.Sqlizer, error) {
|
||||
if _, value, info, ok := singleField(values); ok && (info.IsTag || info.IsRole) {
|
||||
return jsonExpr(info, makeCond(map[string]any{"value": value}), negateJSON), nil
|
||||
}
|
||||
fields, err := sqlFields(values)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return makeCond(fields), nil
|
||||
}
|
||||
|
||||
func likeExpr[T ~map[string]any](values T, pattern string, negate bool) (squirrel.Sqlizer, error) {
|
||||
if _, value, info, ok := singleField(values); ok && (info.IsTag || info.IsRole) {
|
||||
return jsonExpr(info, squirrel.Like{"value": fmt.Sprintf(pattern, value)}, negate), nil
|
||||
}
|
||||
fields, err := sqlFields(values)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if negate {
|
||||
lk := squirrel.NotLike{}
|
||||
for field, value := range fields {
|
||||
lk[field] = fmt.Sprintf(pattern, value)
|
||||
}
|
||||
return lk, nil
|
||||
}
|
||||
lk := squirrel.Like{}
|
||||
for field, value := range fields {
|
||||
lk[field] = fmt.Sprintf(pattern, value)
|
||||
}
|
||||
return lk, nil
|
||||
}
|
||||
|
||||
func rangeExpr[T ~map[string]any](values T) (squirrel.Sqlizer, error) {
|
||||
fields, err := sqlFields(values)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
and := squirrel.And{}
|
||||
for field, value := range fields {
|
||||
s := reflect.ValueOf(value)
|
||||
if s.Kind() != reflect.Slice || s.Len() != 2 {
|
||||
return nil, fmt.Errorf("invalid range for 'in' operator: %s", value)
|
||||
}
|
||||
and = append(and,
|
||||
squirrel.GtOrEq{field: s.Index(0).Interface()},
|
||||
squirrel.LtOrEq{field: s.Index(1).Interface()},
|
||||
)
|
||||
}
|
||||
return and, nil
|
||||
}
|
||||
|
||||
func periodExpr[T ~map[string]any](values T, negate bool) (squirrel.Sqlizer, error) {
|
||||
fields, err := sqlFields(values)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var field string
|
||||
var value any
|
||||
for f, v := range fields {
|
||||
field, value = f, v
|
||||
break
|
||||
}
|
||||
days, err := strconv.ParseInt(fmt.Sprintf("%v", value), 10, 64)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
firstDate := startOfPeriod(days, time.Now())
|
||||
if negate {
|
||||
return squirrel.Or{
|
||||
squirrel.Lt{field: firstDate},
|
||||
squirrel.Eq{field: nil},
|
||||
}, nil
|
||||
}
|
||||
return squirrel.Gt{field: firstDate}, nil
|
||||
}
|
||||
|
||||
func startOfPeriod(numDays int64, from time.Time) string {
|
||||
return from.Add(time.Duration(-24*numDays) * time.Hour).Format("2006-01-02")
|
||||
}
|
||||
|
||||
func inList[T ~map[string]any](values T, negate bool) (squirrel.Sqlizer, error) {
|
||||
playlistID, ok := values["id"].(string)
|
||||
if !ok {
|
||||
return nil, errors.New("playlist id not given")
|
||||
}
|
||||
subQuery := squirrel.Select("media_file_id").
|
||||
From("playlist_tracks pl").
|
||||
LeftJoin("playlist on pl.playlist_id = playlist.id").
|
||||
Where(squirrel.And{
|
||||
squirrel.Eq{"pl.playlist_id": playlistID},
|
||||
squirrel.Eq{"playlist.public": 1},
|
||||
})
|
||||
subSQL, subArgs, err := subQuery.PlaceholderFormat(squirrel.Question).ToSql()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if negate {
|
||||
return squirrel.Expr("media_file.id NOT IN ("+subSQL+")", subArgs...), nil
|
||||
}
|
||||
return squirrel.Expr("media_file.id IN ("+subSQL+")", subArgs...), nil
|
||||
}
|
||||
|
||||
func jsonExpr(info criteria.FieldInfo, cond squirrel.Sqlizer, negate bool) squirrel.Sqlizer {
|
||||
if info.IsRole {
|
||||
return roleCond{role: info.Name, cond: cond, not: negate}
|
||||
}
|
||||
return tagCond{tag: info.Name, numeric: info.Numeric, cond: cond, not: negate}
|
||||
}
|
||||
|
||||
type tagCond struct {
|
||||
tag string
|
||||
numeric bool
|
||||
cond squirrel.Sqlizer
|
||||
not bool
|
||||
}
|
||||
|
||||
func (e tagCond) ToSql() (string, []any, error) {
|
||||
cond, args, err := e.cond.ToSql()
|
||||
if e.numeric {
|
||||
cond = strings.ReplaceAll(cond, "value", "CAST(value AS REAL)")
|
||||
}
|
||||
cond = fmt.Sprintf("exists (select 1 from json_tree(media_file.tags, '$.%s') where key='value' and %s)", e.tag, cond)
|
||||
if e.not {
|
||||
cond = "not " + cond
|
||||
}
|
||||
return cond, args, err
|
||||
}
|
||||
|
||||
type roleCond struct {
|
||||
role string
|
||||
cond squirrel.Sqlizer
|
||||
not bool
|
||||
}
|
||||
|
||||
func (e roleCond) ToSql() (string, []any, error) {
|
||||
cond, args, err := e.cond.ToSql()
|
||||
cond = fmt.Sprintf("exists (select 1 from json_tree(media_file.participants, '$.%s') where key='name' and %s)", e.role, cond)
|
||||
if e.not {
|
||||
cond = "not " + cond
|
||||
}
|
||||
return cond, args, err
|
||||
}
|
||||
|
||||
func singleField[T ~map[string]any](values T) (string, any, criteria.FieldInfo, bool) {
|
||||
if len(values) != 1 {
|
||||
return "", nil, criteria.FieldInfo{}, false
|
||||
}
|
||||
for field, value := range values {
|
||||
info, ok := criteria.LookupField(field)
|
||||
return field, value, info, ok
|
||||
}
|
||||
return "", nil, criteria.FieldInfo{}, false
|
||||
}
|
||||
|
||||
func sqlFields[T ~map[string]any](values T) (map[string]any, error) {
|
||||
fields := make(map[string]any, len(values))
|
||||
for field, value := range values {
|
||||
info, ok := criteria.LookupField(field)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("invalid field in criteria: %s", field)
|
||||
}
|
||||
if info.IsTag || info.IsRole {
|
||||
return nil, fmt.Errorf("tag and role criteria must contain exactly one field: %s", field)
|
||||
}
|
||||
sqlField, ok := fieldExpr(info.Name)
|
||||
if !ok || sqlField == "" {
|
||||
return nil, fmt.Errorf("invalid field in criteria: %s", field)
|
||||
}
|
||||
fields[sqlField] = value
|
||||
}
|
||||
return fields, nil
|
||||
}
|
||||
|
||||
func fieldExpr(name string) (string, bool) {
|
||||
field, ok := smartPlaylistFields[strings.ToLower(name)]
|
||||
return field.expr, ok
|
||||
}
|
||||
|
||||
func fieldJoinType(name string) smartPlaylistJoinType {
|
||||
info, ok := criteria.LookupField(name)
|
||||
if !ok {
|
||||
return smartPlaylistJoinNone
|
||||
}
|
||||
field, ok := smartPlaylistFields[info.Name]
|
||||
if !ok {
|
||||
return smartPlaylistJoinNone
|
||||
}
|
||||
return field.joinType
|
||||
}
|
||||
|
||||
func (c smartPlaylistCriteria) ExpressionJoins() smartPlaylistJoinType {
|
||||
var joins smartPlaylistJoinType
|
||||
_ = criteria.Walk(c.criteria.Expression, func(expr criteria.Expression) error {
|
||||
for field := range criteria.Fields(expr) {
|
||||
joins |= fieldJoinType(field)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
return joins
|
||||
}
|
||||
|
||||
func (c smartPlaylistCriteria) RequiredJoins() smartPlaylistJoinType {
|
||||
joins := c.ExpressionJoins()
|
||||
for _, sortField := range sortFields(c.criteria.Sort) {
|
||||
joins |= fieldJoinType(sortField)
|
||||
}
|
||||
return joins
|
||||
}
|
||||
|
||||
func (c smartPlaylistCriteria) OrderBy() string {
|
||||
sortValue := c.criteria.Sort
|
||||
if sortValue == "" {
|
||||
sortValue = "title"
|
||||
}
|
||||
|
||||
order := strings.ToLower(strings.TrimSpace(c.criteria.Order))
|
||||
if order != "" && order != "asc" && order != "desc" {
|
||||
log.Error("Invalid value in 'order' field. Valid values: 'asc', 'desc'", "order", c.criteria.Order)
|
||||
order = ""
|
||||
}
|
||||
|
||||
parts := strings.Split(sortValue, ",")
|
||||
fields := make([]string, 0, len(parts))
|
||||
for _, part := range parts {
|
||||
part = strings.TrimSpace(part)
|
||||
if part == "" {
|
||||
continue
|
||||
}
|
||||
dir := "asc"
|
||||
if strings.HasPrefix(part, "+") || strings.HasPrefix(part, "-") {
|
||||
if strings.HasPrefix(part, "-") {
|
||||
dir = "desc"
|
||||
}
|
||||
part = strings.TrimSpace(part[1:])
|
||||
}
|
||||
sortField := strings.ToLower(part)
|
||||
mapped, ok := sortExpr(sortField)
|
||||
if !ok {
|
||||
log.Error("Invalid field in 'sort' field", "sort", sortField)
|
||||
continue
|
||||
}
|
||||
if order == "desc" {
|
||||
if dir == "asc" {
|
||||
dir = "desc"
|
||||
} else {
|
||||
dir = "asc"
|
||||
}
|
||||
}
|
||||
fields = append(fields, mapped+" "+dir)
|
||||
}
|
||||
return strings.Join(fields, ", ")
|
||||
}
|
||||
|
||||
func sortFields(sortValue string) []string {
|
||||
if sortValue == "" {
|
||||
sortValue = "title"
|
||||
}
|
||||
parts := strings.Split(sortValue, ",")
|
||||
fields := make([]string, 0, len(parts))
|
||||
for _, part := range parts {
|
||||
part = strings.TrimSpace(strings.TrimLeft(strings.TrimSpace(part), "+-"))
|
||||
if part != "" {
|
||||
fields = append(fields, part)
|
||||
}
|
||||
}
|
||||
return fields
|
||||
}
|
||||
|
||||
func sortExpr(sortField string) (string, bool) {
|
||||
info, ok := criteria.LookupField(sortField)
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
if field, ok := smartPlaylistFields[info.Name]; ok && field.order != "" {
|
||||
return field.order, true
|
||||
}
|
||||
var mapped string
|
||||
switch {
|
||||
case info.IsTag:
|
||||
mapped = "COALESCE(json_extract(media_file.tags, '$." + info.Name + "[0].value'), '')"
|
||||
case info.IsRole:
|
||||
mapped = "COALESCE(json_extract(media_file.participants, '$." + info.Name + "[0].name'), '')"
|
||||
default:
|
||||
field, ok := smartPlaylistFields[info.Name]
|
||||
if !ok || field.expr == "" {
|
||||
return "", false
|
||||
}
|
||||
mapped = field.expr
|
||||
}
|
||||
if info.Numeric {
|
||||
mapped = fmt.Sprintf("CAST(%s AS REAL)", mapped)
|
||||
}
|
||||
return mapped, true
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
package persistence
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/model/criteria"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("Smart playlist criteria SQL", func() {
|
||||
BeforeEach(func() {
|
||||
criteria.AddRoles([]string{"artist", "composer", "producer"})
|
||||
criteria.AddTagNames([]string{"genre", "mood", "releasetype"})
|
||||
criteria.AddNumericTags([]string{"rate"})
|
||||
})
|
||||
|
||||
DescribeTable("expressions",
|
||||
func(expr criteria.Expression, expectedSQL string, expectedArgs ...any) {
|
||||
sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: expr}).Where()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
sql, args, err := sqlizer.ToSql()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(sql).To(Equal(expectedSQL))
|
||||
Expect(args).To(HaveExactElements(expectedArgs...))
|
||||
},
|
||||
Entry("all group",
|
||||
criteria.All{criteria.Contains{"title": "love"}, criteria.Gt{"rating": 3}},
|
||||
"(media_file.title LIKE ? AND COALESCE(annotation.rating, 0) > ?)", "%love%", 3),
|
||||
Entry("any group",
|
||||
criteria.Any{criteria.Is{"title": "Low Rider"}, criteria.Is{"album": "Best Of"}},
|
||||
"(media_file.title = ? OR media_file.album = ?)", "Low Rider", "Best Of"),
|
||||
Entry("is string", criteria.Is{"title": "Low Rider"}, "media_file.title = ?", "Low Rider"),
|
||||
Entry("is bool", criteria.Is{"loved": true}, "COALESCE(annotation.starred, false) = ?", true),
|
||||
Entry("is numeric list", criteria.Is{"library_id": []int{1, 2}}, "media_file.library_id IN (?,?)", 1, 2),
|
||||
Entry("is not", criteria.IsNot{"title": "Low Rider"}, "media_file.title <> ?", "Low Rider"),
|
||||
Entry("gt", criteria.Gt{"playCount": 10}, "COALESCE(annotation.play_count, 0) > ?", 10),
|
||||
Entry("lt", criteria.Lt{"playCount": 10}, "COALESCE(annotation.play_count, 0) < ?", 10),
|
||||
Entry("contains", criteria.Contains{"title": "Low Rider"}, "media_file.title LIKE ?", "%Low Rider%"),
|
||||
Entry("not contains", criteria.NotContains{"title": "Low Rider"}, "media_file.title NOT LIKE ?", "%Low Rider%"),
|
||||
Entry("starts with", criteria.StartsWith{"title": "Low Rider"}, "media_file.title LIKE ?", "Low Rider%"),
|
||||
Entry("ends with", criteria.EndsWith{"title": "Low Rider"}, "media_file.title LIKE ?", "%Low Rider"),
|
||||
Entry("in range", criteria.InTheRange{"year": []int{1980, 1990}}, "(media_file.year >= ? AND media_file.year <= ?)", 1980, 1990),
|
||||
Entry("before", criteria.Before{"lastPlayed": time.Date(2021, 10, 1, 0, 0, 0, 0, time.Local)}, "annotation.play_date < ?", time.Date(2021, 10, 1, 0, 0, 0, 0, time.Local)),
|
||||
Entry("after", criteria.After{"lastPlayed": time.Date(2021, 10, 1, 0, 0, 0, 0, time.Local)}, "annotation.play_date > ?", time.Date(2021, 10, 1, 0, 0, 0, 0, time.Local)),
|
||||
Entry("in playlist", criteria.InPlaylist{"id": "deadbeef-dead-beef"}, "media_file.id IN (SELECT media_file_id FROM playlist_tracks pl LEFT JOIN playlist on pl.playlist_id = playlist.id WHERE (pl.playlist_id = ? AND playlist.public = ?))", "deadbeef-dead-beef", 1),
|
||||
Entry("not in playlist", criteria.NotInPlaylist{"id": "deadbeef-dead-beef"}, "media_file.id NOT IN (SELECT media_file_id FROM playlist_tracks pl LEFT JOIN playlist on pl.playlist_id = playlist.id WHERE (pl.playlist_id = ? AND playlist.public = ?))", "deadbeef-dead-beef", 1),
|
||||
Entry("album annotation", criteria.Gt{"albumRating": 3}, "COALESCE(album_annotation.rating, 0) > ?", 3),
|
||||
Entry("artist annotation", criteria.Is{"artistLoved": true}, "COALESCE(artist_annotation.starred, false) = ?", true),
|
||||
Entry("tag is", criteria.Is{"genre": "Rock"}, "exists (select 1 from json_tree(media_file.tags, '$.genre') where key='value' and value = ?)", "Rock"),
|
||||
Entry("tag is not", criteria.IsNot{"genre": "Rock"}, "not exists (select 1 from json_tree(media_file.tags, '$.genre') where key='value' and value = ?)", "Rock"),
|
||||
Entry("tag contains", criteria.Contains{"genre": "Rock"}, "exists (select 1 from json_tree(media_file.tags, '$.genre') where key='value' and value LIKE ?)", "%Rock%"),
|
||||
Entry("tag not contains", criteria.NotContains{"genre": "Rock"}, "not exists (select 1 from json_tree(media_file.tags, '$.genre') where key='value' and value LIKE ?)", "%Rock%"),
|
||||
Entry("numeric tag", criteria.Lt{"rate": 6}, "exists (select 1 from json_tree(media_file.tags, '$.rate') where key='value' and CAST(value AS REAL) < ?)", 6),
|
||||
Entry("tag alias", criteria.Is{"albumtype": "album"}, "exists (select 1 from json_tree(media_file.tags, '$.releasetype') where key='value' and value = ?)", "album"),
|
||||
Entry("role is", criteria.Is{"artist": "u2"}, "exists (select 1 from json_tree(media_file.participants, '$.artist') where key='name' and value = ?)", "u2"),
|
||||
Entry("role contains", criteria.Contains{"composer": "Lennon"}, "exists (select 1 from json_tree(media_file.participants, '$.composer') where key='name' and value LIKE ?)", "%Lennon%"),
|
||||
Entry("role not contains", criteria.NotContains{"artist": "u2"}, "not exists (select 1 from json_tree(media_file.participants, '$.artist') where key='name' and value LIKE ?)", "%u2%"),
|
||||
)
|
||||
|
||||
It("builds relative date expressions", func() {
|
||||
sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: criteria.InTheLast{"lastPlayed": 30}}).Where()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
sql, args, err := sqlizer.ToSql()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(sql).To(Equal("annotation.play_date > ?"))
|
||||
Expect(args).To(HaveExactElements(startOfPeriod(30, time.Now())))
|
||||
})
|
||||
|
||||
It("builds negated relative date expressions", func() {
|
||||
sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: criteria.NotInTheLast{"lastPlayed": 30}}).Where()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
sql, args, err := sqlizer.ToSql()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(sql).To(Equal("(annotation.play_date < ? OR annotation.play_date IS NULL)"))
|
||||
Expect(args).To(HaveExactElements(startOfPeriod(30, time.Now())))
|
||||
})
|
||||
|
||||
It("returns an error for unknown fields", func() {
|
||||
_, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: criteria.EndsWith{"unknown": "value"}}).Where()
|
||||
|
||||
Expect(err).To(MatchError("invalid field in criteria: unknown"))
|
||||
})
|
||||
|
||||
Describe("sort", func() {
|
||||
It("sorts by regular fields", func() {
|
||||
Expect(newSmartPlaylistCriteria(criteria.Criteria{Sort: "title"}).OrderBy()).To(Equal("media_file.title asc"))
|
||||
})
|
||||
|
||||
It("sorts by tag fields", func() {
|
||||
Expect(newSmartPlaylistCriteria(criteria.Criteria{Sort: "genre"}).OrderBy()).To(Equal("COALESCE(json_extract(media_file.tags, '$.genre[0].value'), '') asc"))
|
||||
})
|
||||
|
||||
It("sorts by role fields", func() {
|
||||
Expect(newSmartPlaylistCriteria(criteria.Criteria{Sort: "artist"}).OrderBy()).To(Equal("COALESCE(json_extract(media_file.participants, '$.artist[0].name'), '') asc"))
|
||||
})
|
||||
|
||||
It("casts numeric tags when sorting", func() {
|
||||
Expect(newSmartPlaylistCriteria(criteria.Criteria{Sort: "rate"}).OrderBy()).To(Equal("CAST(COALESCE(json_extract(media_file.tags, '$.rate[0].value'), '') AS REAL) asc"))
|
||||
})
|
||||
|
||||
It("sorts by albumtype alias", func() {
|
||||
Expect(newSmartPlaylistCriteria(criteria.Criteria{Sort: "albumtype"}).OrderBy()).To(Equal("COALESCE(json_extract(media_file.tags, '$.releasetype[0].value'), '') asc"))
|
||||
})
|
||||
|
||||
It("sorts by random", func() {
|
||||
Expect(newSmartPlaylistCriteria(criteria.Criteria{Sort: "random"}).OrderBy()).To(Equal("random() asc"))
|
||||
})
|
||||
|
||||
It("sorts by multiple fields", func() {
|
||||
Expect(newSmartPlaylistCriteria(criteria.Criteria{Sort: "title,-rating"}).OrderBy()).To(Equal("media_file.title asc, COALESCE(annotation.rating, 0) desc"))
|
||||
})
|
||||
|
||||
It("reverts order when order is desc", func() {
|
||||
Expect(newSmartPlaylistCriteria(criteria.Criteria{Sort: "-date,artist", Order: "desc"}).OrderBy()).To(Equal("media_file.date asc, COALESCE(json_extract(media_file.participants, '$.artist[0].name'), '') desc"))
|
||||
})
|
||||
|
||||
It("ignores invalid sort fields", func() {
|
||||
Expect(newSmartPlaylistCriteria(criteria.Criteria{Sort: "bogus,title"}).OrderBy()).To(Equal("media_file.title asc"))
|
||||
})
|
||||
})
|
||||
|
||||
It("has SQL mappings for all non-tag/non-role criteria fields", func() {
|
||||
for _, name := range criteria.AllFieldNames() {
|
||||
info, ok := criteria.LookupField(name)
|
||||
Expect(ok).To(BeTrue(), "field %q registered but LookupField fails", name)
|
||||
if info.IsTag || info.IsRole {
|
||||
continue
|
||||
}
|
||||
_, hasSQLField := smartPlaylistFields[info.Name]
|
||||
Expect(hasSQLField).To(BeTrue(), "criteria field %q (name=%q) has no entry in smartPlaylistFields", name, info.Name)
|
||||
}
|
||||
})
|
||||
|
||||
Describe("joins", func() {
|
||||
It("excludes sort-only joins from expression joins", func() {
|
||||
c := criteria.Criteria{Expression: criteria.All{criteria.Contains{"title": "love"}}, Sort: "albumRating"}
|
||||
cSQL := newSmartPlaylistCriteria(c)
|
||||
|
||||
Expect(cSQL.ExpressionJoins()).To(Equal(smartPlaylistJoinNone))
|
||||
Expect(cSQL.RequiredJoins().has(smartPlaylistJoinAlbumAnnotation)).To(BeTrue())
|
||||
})
|
||||
|
||||
It("includes expression-based joins", func() {
|
||||
c := criteria.Criteria{Expression: criteria.All{criteria.Gt{"albumRating": 3}}}
|
||||
|
||||
Expect(newSmartPlaylistCriteria(c).ExpressionJoins().has(smartPlaylistJoinAlbumAnnotation)).To(BeTrue())
|
||||
})
|
||||
|
||||
It("detects nested album and artist joins", func() {
|
||||
c := criteria.Criteria{Expression: criteria.All{
|
||||
criteria.Any{criteria.All{criteria.Is{"albumLoved": true}}},
|
||||
criteria.Any{criteria.Gt{"artistPlayCount": 10}},
|
||||
}}
|
||||
|
||||
joins := newSmartPlaylistCriteria(c).RequiredJoins()
|
||||
Expect(joins.has(smartPlaylistJoinAlbumAnnotation)).To(BeTrue())
|
||||
Expect(joins.has(smartPlaylistJoinArtistAnnotation)).To(BeTrue())
|
||||
})
|
||||
|
||||
It("detects join types from sort fields with direction prefixes", func() {
|
||||
c := criteria.Criteria{Expression: criteria.All{criteria.Contains{"title": "love"}}, Sort: "-artistRating"}
|
||||
|
||||
Expect(newSmartPlaylistCriteria(c).RequiredJoins().has(smartPlaylistJoinArtistAnnotation)).To(BeTrue())
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -228,6 +228,7 @@ func (r *playlistRepository) refreshSmartPlaylist(pls *model.Playlist) bool {
|
||||
|
||||
// Re-populate playlist based on Smart Playlist criteria
|
||||
rules := *pls.Rules
|
||||
rulesSQL := newSmartPlaylistCriteria(rules)
|
||||
|
||||
// If the playlist depends on other playlists, recursively refresh them first
|
||||
childPlaylistIds := rules.ChildPlaylistIds()
|
||||
@@ -240,14 +241,15 @@ func (r *playlistRepository) refreshSmartPlaylist(pls *model.Playlist) bool {
|
||||
r.refreshSmartPlaylist(childPls)
|
||||
}
|
||||
|
||||
sq := Select("row_number() over (order by "+rules.OrderBy()+") as id", "'"+pls.ID+"' as playlist_id", "media_file.id as media_file_id").
|
||||
orderBy := rulesSQL.OrderBy()
|
||||
sq := Select("row_number() over (order by "+orderBy+") as id", "'"+pls.ID+"' as playlist_id", "media_file.id as media_file_id").
|
||||
From("media_file").LeftJoin("annotation on ("+
|
||||
"annotation.item_id = media_file.id"+
|
||||
" AND annotation.item_type = 'media_file'"+
|
||||
" AND annotation.user_id = ?)", usr.ID)
|
||||
|
||||
// Conditionally join album/artist annotation tables only when referenced by criteria or sort
|
||||
requiredJoins := rules.RequiredJoins()
|
||||
requiredJoins := rulesSQL.RequiredJoins()
|
||||
sq = r.addSmartPlaylistAnnotationJoins(sq, requiredJoins, usr.ID)
|
||||
|
||||
// Only include media files from libraries the user has access to
|
||||
@@ -256,7 +258,7 @@ func (r *playlistRepository) refreshSmartPlaylist(pls *model.Playlist) bool {
|
||||
// Resolve percentage-based limit to an absolute number before applying criteria
|
||||
if rules.IsPercentageLimit() {
|
||||
// Use only expression-based joins for the COUNT query (sort joins are unnecessary)
|
||||
exprJoins := rules.ExpressionJoins()
|
||||
exprJoins := rulesSQL.ExpressionJoins()
|
||||
countSq := Select("count(*) as count").From("media_file").
|
||||
LeftJoin("annotation on ("+
|
||||
"annotation.item_id = media_file.id"+
|
||||
@@ -264,7 +266,12 @@ func (r *playlistRepository) refreshSmartPlaylist(pls *model.Playlist) bool {
|
||||
" AND annotation.user_id = ?)", usr.ID)
|
||||
countSq = r.addSmartPlaylistAnnotationJoins(countSq, exprJoins, usr.ID)
|
||||
countSq = r.applyLibraryFilter(countSq, "media_file")
|
||||
countSq = countSq.Where(rules)
|
||||
cond, err := rulesSQL.Where()
|
||||
if err != nil {
|
||||
log.Error(r.ctx, "Error building smart playlist criteria", "playlist", pls.Name, "id", pls.ID, err)
|
||||
return false
|
||||
}
|
||||
countSq = countSq.Where(cond)
|
||||
|
||||
var res struct{ Count int64 }
|
||||
err = r.queryOne(countSq, &res)
|
||||
@@ -279,7 +286,11 @@ func (r *playlistRepository) refreshSmartPlaylist(pls *model.Playlist) bool {
|
||||
}
|
||||
|
||||
// Apply the criteria rules
|
||||
sq = r.addCriteria(sq, rules)
|
||||
sq, err = r.addCriteria(sq, rules)
|
||||
if err != nil {
|
||||
log.Error(r.ctx, "Error building smart playlist criteria", "playlist", pls.Name, "id", pls.ID, err)
|
||||
return false
|
||||
}
|
||||
insSql := Insert("playlist_tracks").Columns("id", "playlist_id", "media_file_id").Select(sq)
|
||||
_, err = r.executeSQL(insSql)
|
||||
if err != nil {
|
||||
@@ -310,14 +321,14 @@ func (r *playlistRepository) refreshSmartPlaylist(pls *model.Playlist) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
func (r *playlistRepository) addSmartPlaylistAnnotationJoins(sq SelectBuilder, joins criteria.JoinType, userID string) SelectBuilder {
|
||||
if joins.Has(criteria.JoinAlbumAnnotation) {
|
||||
func (r *playlistRepository) addSmartPlaylistAnnotationJoins(sq SelectBuilder, joins smartPlaylistJoinType, userID string) SelectBuilder {
|
||||
if joins.has(smartPlaylistJoinAlbumAnnotation) {
|
||||
sq = sq.LeftJoin("annotation AS album_annotation ON ("+
|
||||
"album_annotation.item_id = media_file.album_id"+
|
||||
" AND album_annotation.item_type = 'album'"+
|
||||
" AND album_annotation.user_id = ?)", userID)
|
||||
}
|
||||
if joins.Has(criteria.JoinArtistAnnotation) {
|
||||
if joins.has(smartPlaylistJoinArtistAnnotation) {
|
||||
sq = sq.LeftJoin("annotation AS artist_annotation ON ("+
|
||||
"artist_annotation.item_id = media_file.artist_id"+
|
||||
" AND artist_annotation.item_type = 'artist'"+
|
||||
@@ -326,15 +337,20 @@ func (r *playlistRepository) addSmartPlaylistAnnotationJoins(sq SelectBuilder, j
|
||||
return sq
|
||||
}
|
||||
|
||||
func (r *playlistRepository) addCriteria(sql SelectBuilder, c criteria.Criteria) SelectBuilder {
|
||||
sql = sql.Where(c)
|
||||
func (r *playlistRepository) addCriteria(sql SelectBuilder, c criteria.Criteria) (SelectBuilder, error) {
|
||||
cSQL := newSmartPlaylistCriteria(c)
|
||||
cond, err := cSQL.Where()
|
||||
if err != nil {
|
||||
return sql, err
|
||||
}
|
||||
sql = sql.Where(cond)
|
||||
if c.Limit > 0 {
|
||||
sql = sql.Limit(uint64(c.Limit)).Offset(uint64(c.Offset))
|
||||
}
|
||||
if order := c.OrderBy(); order != "" {
|
||||
if order := cSQL.OrderBy(); order != "" {
|
||||
sql = sql.OrderBy(order)
|
||||
}
|
||||
return sql
|
||||
return sql, nil
|
||||
}
|
||||
|
||||
func (r *playlistRepository) updateTracks(id string, tracks model.MediaFiles) error {
|
||||
|
||||
Reference in New Issue
Block a user