update
Some checks failed
Pipeline: Test, Lint, Build / Get version info (push) Has been cancelled
Pipeline: Test, Lint, Build / Lint Go code (push) Has been cancelled
Pipeline: Test, Lint, Build / Test Go code (push) Has been cancelled
Pipeline: Test, Lint, Build / Test JS code (push) Has been cancelled
Pipeline: Test, Lint, Build / Lint i18n files (push) Has been cancelled
Pipeline: Test, Lint, Build / Check Docker configuration (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (darwin/amd64) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (darwin/arm64) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (linux/386) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (linux/amd64) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (linux/arm/v5) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (linux/arm/v6) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (linux/arm/v7) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (linux/arm64) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (windows/386) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (windows/amd64) (push) Has been cancelled
Pipeline: Test, Lint, Build / Push to GHCR (push) Has been cancelled
Pipeline: Test, Lint, Build / Push to Docker Hub (push) Has been cancelled
Pipeline: Test, Lint, Build / Cleanup digest artifacts (push) Has been cancelled
Pipeline: Test, Lint, Build / Build Windows installers (push) Has been cancelled
Pipeline: Test, Lint, Build / Package/Release (push) Has been cancelled
Pipeline: Test, Lint, Build / Upload Linux PKG (push) Has been cancelled
Close stale issues and PRs / stale (push) Has been cancelled
POEditor import / update-translations (push) Has been cancelled
Some checks failed
Pipeline: Test, Lint, Build / Get version info (push) Has been cancelled
Pipeline: Test, Lint, Build / Lint Go code (push) Has been cancelled
Pipeline: Test, Lint, Build / Test Go code (push) Has been cancelled
Pipeline: Test, Lint, Build / Test JS code (push) Has been cancelled
Pipeline: Test, Lint, Build / Lint i18n files (push) Has been cancelled
Pipeline: Test, Lint, Build / Check Docker configuration (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (darwin/amd64) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (darwin/arm64) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (linux/386) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (linux/amd64) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (linux/arm/v5) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (linux/arm/v6) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (linux/arm/v7) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (linux/arm64) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (windows/386) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (windows/amd64) (push) Has been cancelled
Pipeline: Test, Lint, Build / Push to GHCR (push) Has been cancelled
Pipeline: Test, Lint, Build / Push to Docker Hub (push) Has been cancelled
Pipeline: Test, Lint, Build / Cleanup digest artifacts (push) Has been cancelled
Pipeline: Test, Lint, Build / Build Windows installers (push) Has been cancelled
Pipeline: Test, Lint, Build / Package/Release (push) Has been cancelled
Pipeline: Test, Lint, Build / Upload Linux PKG (push) Has been cancelled
Close stale issues and PRs / stale (push) Has been cancelled
POEditor import / update-translations (push) Has been cancelled
This commit is contained in:
159
model/criteria/criteria.go
Normal file
159
model/criteria/criteria.go
Normal file
@@ -0,0 +1,159 @@
|
||||
// Package criteria implements a Criteria API based on Masterminds/squirrel
|
||||
package criteria
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/Masterminds/squirrel"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
)
|
||||
|
||||
type Expression = squirrel.Sqlizer
|
||||
|
||||
type Criteria struct {
|
||||
Expression
|
||||
Sort string
|
||||
Order string
|
||||
Limit int
|
||||
Offset int
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
func (c Criteria) ChildPlaylistIds() []string {
|
||||
if c.Expression == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
if parent := c.Expression.(interface{ ChildPlaylistIds() (ids []string) }); parent != nil {
|
||||
return parent.ChildPlaylistIds()
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c Criteria) MarshalJSON() ([]byte, error) {
|
||||
aux := struct {
|
||||
All []Expression `json:"all,omitempty"`
|
||||
Any []Expression `json:"any,omitempty"`
|
||||
Sort string `json:"sort,omitempty"`
|
||||
Order string `json:"order,omitempty"`
|
||||
Limit int `json:"limit,omitempty"`
|
||||
Offset int `json:"offset,omitempty"`
|
||||
}{
|
||||
Sort: c.Sort,
|
||||
Order: c.Order,
|
||||
Limit: c.Limit,
|
||||
Offset: c.Offset,
|
||||
}
|
||||
switch rules := c.Expression.(type) {
|
||||
case Any:
|
||||
aux.Any = rules
|
||||
case All:
|
||||
aux.All = rules
|
||||
default:
|
||||
aux.All = All{rules}
|
||||
}
|
||||
return json.Marshal(aux)
|
||||
}
|
||||
|
||||
func (c *Criteria) UnmarshalJSON(data []byte) error {
|
||||
var aux struct {
|
||||
All unmarshalConjunctionType `json:"all"`
|
||||
Any unmarshalConjunctionType `json:"any"`
|
||||
Sort string `json:"sort"`
|
||||
Order string `json:"order"`
|
||||
Limit int `json:"limit"`
|
||||
Offset int `json:"offset"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &aux); err != nil {
|
||||
return err
|
||||
}
|
||||
if len(aux.Any) > 0 {
|
||||
c.Expression = Any(aux.Any)
|
||||
} else if len(aux.All) > 0 {
|
||||
c.Expression = All(aux.All)
|
||||
} else {
|
||||
return errors.New("invalid criteria json. missing rules (key 'all' or 'any')")
|
||||
}
|
||||
c.Sort = aux.Sort
|
||||
c.Order = aux.Order
|
||||
c.Limit = aux.Limit
|
||||
c.Offset = aux.Offset
|
||||
return nil
|
||||
}
|
||||
17
model/criteria/criteria_suite_test.go
Normal file
17
model/criteria/criteria_suite_test.go
Normal file
@@ -0,0 +1,17 @@
|
||||
package criteria
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
"github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
func TestCriteria(t *testing.T) {
|
||||
log.SetLevel(log.LevelFatal)
|
||||
gomega.RegisterFailHandler(Fail)
|
||||
// Register `genre` as a tag name, so we can use it in tests
|
||||
RunSpecs(t, "Criteria Suite")
|
||||
}
|
||||
248
model/criteria/criteria_test.go
Normal file
248
model/criteria/criteria_test.go
Normal file
@@ -0,0 +1,248 @@
|
||||
package criteria
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
|
||||
"github.com/google/uuid"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
"github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("Criteria", func() {
|
||||
var goObj Criteria
|
||||
var jsonObj string
|
||||
|
||||
Context("with a complex criteria", func() {
|
||||
BeforeEach(func() {
|
||||
goObj = Criteria{
|
||||
Expression: All{
|
||||
Contains{"title": "love"},
|
||||
NotContains{"title": "hate"},
|
||||
Any{
|
||||
IsNot{"artist": "u2"},
|
||||
Is{"album": "best of"},
|
||||
},
|
||||
All{
|
||||
StartsWith{"comment": "this"},
|
||||
InTheRange{"year": []int{1980, 1990}},
|
||||
IsNot{"genre": "Rock"},
|
||||
},
|
||||
},
|
||||
Sort: "title",
|
||||
Order: "asc",
|
||||
Limit: 20,
|
||||
Offset: 10,
|
||||
}
|
||||
var b bytes.Buffer
|
||||
err := json.Compact(&b, []byte(`
|
||||
{
|
||||
"all": [
|
||||
{ "contains": {"title": "love"} },
|
||||
{ "notContains": {"title": "hate"} },
|
||||
{ "any": [
|
||||
{ "isNot": {"artist": "u2"} },
|
||||
{ "is": {"album": "best of"} }
|
||||
]
|
||||
},
|
||||
{ "all": [
|
||||
{ "startsWith": {"comment": "this"} },
|
||||
{ "inTheRange": {"year":[1980,1990]} },
|
||||
{ "isNot": { "genre": "Rock" }}
|
||||
]
|
||||
}
|
||||
],
|
||||
"sort": "title",
|
||||
"order": "asc",
|
||||
"limit": 20,
|
||||
"offset": 10
|
||||
}
|
||||
`))
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
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(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(tags, '$.genre') where key='value' and value = ?)))`))
|
||||
gomega.Expect(args).To(gomega.HaveExactElements("%love%", "%hate%", "u2", "best of", "this%", 1980, 1990, "Rock"))
|
||||
})
|
||||
It("marshals to JSON", func() {
|
||||
j, err := json.Marshal(goObj)
|
||||
gomega.Expect(err).ToNot(gomega.HaveOccurred())
|
||||
gomega.Expect(string(j)).To(gomega.Equal(jsonObj))
|
||||
})
|
||||
It("is reversible to/from JSON", func() {
|
||||
var newObj Criteria
|
||||
err := json.Unmarshal([]byte(jsonObj), &newObj)
|
||||
gomega.Expect(err).ToNot(gomega.HaveOccurred())
|
||||
j, err := json.Marshal(newObj)
|
||||
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(participants, '$.artist') where key='name' and value = ?) AND ` +
|
||||
`exists (select 1 from json_tree(participants, '$.composer') where key='name' and value LIKE ?))`,
|
||||
))
|
||||
gomega.Expect(args).To(gomega.HaveExactElements("The Beatles", "%Lennon%"))
|
||||
})
|
||||
})
|
||||
|
||||
Context("with child playlists", func() {
|
||||
var (
|
||||
topLevelInPlaylistID string
|
||||
topLevelNotInPlaylistID string
|
||||
nestedAnyInPlaylistID string
|
||||
nestedAnyNotInPlaylistID string
|
||||
nestedAllInPlaylistID string
|
||||
nestedAllNotInPlaylistID string
|
||||
)
|
||||
BeforeEach(func() {
|
||||
topLevelInPlaylistID = uuid.NewString()
|
||||
topLevelNotInPlaylistID = uuid.NewString()
|
||||
|
||||
nestedAnyInPlaylistID = uuid.NewString()
|
||||
nestedAnyNotInPlaylistID = uuid.NewString()
|
||||
|
||||
nestedAllInPlaylistID = uuid.NewString()
|
||||
nestedAllNotInPlaylistID = uuid.NewString()
|
||||
|
||||
goObj = Criteria{
|
||||
Expression: All{
|
||||
InPlaylist{"id": topLevelInPlaylistID},
|
||||
NotInPlaylist{"id": topLevelNotInPlaylistID},
|
||||
Any{
|
||||
InPlaylist{"id": nestedAnyInPlaylistID},
|
||||
NotInPlaylist{"id": nestedAnyNotInPlaylistID},
|
||||
},
|
||||
All{
|
||||
InPlaylist{"id": nestedAllInPlaylistID},
|
||||
NotInPlaylist{"id": nestedAllNotInPlaylistID},
|
||||
},
|
||||
},
|
||||
}
|
||||
})
|
||||
It("extracts all child smart playlist IDs from expression criteria", func() {
|
||||
ids := goObj.ChildPlaylistIds()
|
||||
gomega.Expect(ids).To(gomega.ConsistOf(topLevelInPlaylistID, topLevelNotInPlaylistID, nestedAnyInPlaylistID, nestedAnyNotInPlaylistID, nestedAllInPlaylistID, nestedAllNotInPlaylistID))
|
||||
})
|
||||
It("extracts child smart playlist IDs from deeply nested expression", func() {
|
||||
goObj = Criteria{
|
||||
Expression: Any{
|
||||
Any{
|
||||
All{
|
||||
Any{
|
||||
InPlaylist{"id": nestedAnyInPlaylistID},
|
||||
NotInPlaylist{"id": nestedAnyNotInPlaylistID},
|
||||
Any{
|
||||
All{
|
||||
InPlaylist{"id": nestedAllInPlaylistID},
|
||||
NotInPlaylist{"id": nestedAllNotInPlaylistID},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
ids := goObj.ChildPlaylistIds()
|
||||
gomega.Expect(ids).To(gomega.ConsistOf(nestedAnyInPlaylistID, nestedAnyNotInPlaylistID, nestedAllInPlaylistID, nestedAllNotInPlaylistID))
|
||||
})
|
||||
It("returns empty list when no child playlist IDs are present", func() {
|
||||
ids := Criteria{}.ChildPlaylistIds()
|
||||
gomega.Expect(ids).To(gomega.BeEmpty())
|
||||
})
|
||||
})
|
||||
})
|
||||
5
model/criteria/export_test.go
Normal file
5
model/criteria/export_test.go
Normal file
@@ -0,0 +1,5 @@
|
||||
package criteria
|
||||
|
||||
var StartOfPeriod = startOfPeriod
|
||||
|
||||
type UnmarshalConjunctionType = unmarshalConjunctionType
|
||||
243
model/criteria/fields.go
Normal file
243
model/criteria/fields.go
Normal file
@@ -0,0 +1,243 @@
|
||||
package criteria
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"reflect"
|
||||
"strings"
|
||||
|
||||
"github.com/Masterminds/squirrel"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
)
|
||||
|
||||
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"},
|
||||
"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"},
|
||||
"duration": {field: "media_file.duration"},
|
||||
"bitrate": {field: "media_file.bit_rate"},
|
||||
"bitdepth": {field: "media_file.bit_depth"},
|
||||
"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)"},
|
||||
"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
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// 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))
|
||||
}
|
||||
|
||||
// Extract into a generic map
|
||||
var k string
|
||||
m := make(map[string]any, rv.Len())
|
||||
for _, key := range rv.MapKeys() {
|
||||
// Save the key to build the expression, and use the provided keyName as the key
|
||||
k = key.String()
|
||||
m["value"] = rv.MapIndex(key).Interface()
|
||||
break // only one key is expected (and supported)
|
||||
}
|
||||
|
||||
// Clear the original map
|
||||
for _, key := range rv.MapKeys() {
|
||||
rv.SetMapIndex(key, reflect.Value{})
|
||||
}
|
||||
|
||||
// Write the updated map back into the original variable
|
||||
for key, val := range m {
|
||||
rv.SetMapIndex(reflect.ValueOf(key), reflect.ValueOf(val))
|
||||
}
|
||||
|
||||
return exprFunc(k, expr, 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(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(participants, '$.%s') where key='name' and %s)`,
|
||||
e.role, cond)
|
||||
if e.not {
|
||||
cond = "not " + cond
|
||||
}
|
||||
return cond, args, err
|
||||
}
|
||||
|
||||
// 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.
|
||||
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}
|
||||
}
|
||||
}
|
||||
|
||||
// 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.
|
||||
func AddTagNames(tagNames []string) {
|
||||
for _, name := range tagNames {
|
||||
name := strings.ToLower(name)
|
||||
if _, ok := fieldMap[name]; ok {
|
||||
continue
|
||||
}
|
||||
for _, fm := range fieldMap {
|
||||
if fm.alias == name {
|
||||
fieldMap[name] = fm
|
||||
break
|
||||
}
|
||||
}
|
||||
if _, ok := fieldMap[name]; !ok {
|
||||
fieldMap[name] = &mappedField{field: name, isTag: true}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// AddNumericTags marks the given tag names as numeric so they can be cast
|
||||
// when used in comparisons or sorting.
|
||||
func AddNumericTags(tagNames []string) {
|
||||
for _, name := range tagNames {
|
||||
name := strings.ToLower(name)
|
||||
if fm, ok := fieldMap[name]; ok {
|
||||
fm.numeric = true
|
||||
} else {
|
||||
fieldMap[name] = &mappedField{field: name, isTag: true, numeric: true}
|
||||
}
|
||||
}
|
||||
}
|
||||
16
model/criteria/fields_test.go
Normal file
16
model/criteria/fields_test.go
Normal file
@@ -0,0 +1,16 @@
|
||||
package criteria
|
||||
|
||||
import (
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
"github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
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())
|
||||
})
|
||||
})
|
||||
})
|
||||
121
model/criteria/json.go
Normal file
121
model/criteria/json.go
Normal file
@@ -0,0 +1,121 @@
|
||||
package criteria
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type unmarshalConjunctionType []Expression
|
||||
|
||||
func (uc *unmarshalConjunctionType) UnmarshalJSON(data []byte) error {
|
||||
var raw []map[string]json.RawMessage
|
||||
if err := json.Unmarshal(data, &raw); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var es unmarshalConjunctionType
|
||||
for _, e := range raw {
|
||||
for k, v := range e {
|
||||
k = strings.ToLower(k)
|
||||
expr := unmarshalExpression(k, v)
|
||||
if expr == nil {
|
||||
expr = unmarshalConjunction(k, v)
|
||||
}
|
||||
if expr == nil {
|
||||
return fmt.Errorf(`invalid expression key '%s'`, k)
|
||||
}
|
||||
es = append(es, expr)
|
||||
}
|
||||
}
|
||||
*uc = es
|
||||
return nil
|
||||
}
|
||||
|
||||
func unmarshalExpression(opName string, rawValue json.RawMessage) Expression {
|
||||
m := make(map[string]any)
|
||||
err := json.Unmarshal(rawValue, &m)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
switch opName {
|
||||
case "is":
|
||||
return Is(m)
|
||||
case "isnot":
|
||||
return IsNot(m)
|
||||
case "gt":
|
||||
return Gt(m)
|
||||
case "lt":
|
||||
return Lt(m)
|
||||
case "contains":
|
||||
return Contains(m)
|
||||
case "notcontains":
|
||||
return NotContains(m)
|
||||
case "startswith":
|
||||
return StartsWith(m)
|
||||
case "endswith":
|
||||
return EndsWith(m)
|
||||
case "intherange":
|
||||
return InTheRange(m)
|
||||
case "before":
|
||||
return Before(m)
|
||||
case "after":
|
||||
return After(m)
|
||||
case "inthelast":
|
||||
return InTheLast(m)
|
||||
case "notinthelast":
|
||||
return NotInTheLast(m)
|
||||
case "inplaylist":
|
||||
return InPlaylist(m)
|
||||
case "notinplaylist":
|
||||
return NotInPlaylist(m)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func unmarshalConjunction(conjName string, rawValue json.RawMessage) Expression {
|
||||
var items unmarshalConjunctionType
|
||||
err := json.Unmarshal(rawValue, &items)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
switch conjName {
|
||||
case "any":
|
||||
return Any(items)
|
||||
case "all":
|
||||
return All(items)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func marshalExpression(name string, value map[string]any) ([]byte, error) {
|
||||
if len(value) != 1 {
|
||||
return nil, fmt.Errorf(`invalid %s expression length %d for values %v`, name, len(value), value)
|
||||
}
|
||||
b := strings.Builder{}
|
||||
b.WriteString(`{"` + name + `":{`)
|
||||
for f, v := range value {
|
||||
j, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
b.WriteString(`"` + f + `":`)
|
||||
b.Write(j)
|
||||
break
|
||||
}
|
||||
b.WriteString("}}")
|
||||
return []byte(b.String()), nil
|
||||
}
|
||||
|
||||
func marshalConjunction(name string, conj []Expression) ([]byte, error) {
|
||||
aux := struct {
|
||||
All []Expression `json:"all,omitempty"`
|
||||
Any []Expression `json:"any,omitempty"`
|
||||
}{}
|
||||
if name == "any" {
|
||||
aux.Any = conj
|
||||
} else {
|
||||
aux.All = conj
|
||||
}
|
||||
return json.Marshal(aux)
|
||||
}
|
||||
353
model/criteria/operators.go
Normal file
353
model/criteria/operators.go
Normal file
@@ -0,0 +1,353 @@
|
||||
package criteria
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/Masterminds/squirrel"
|
||||
)
|
||||
|
||||
type (
|
||||
All squirrel.And
|
||||
And = All
|
||||
)
|
||||
|
||||
func (all All) ToSql() (sql string, args []any, err error) {
|
||||
return squirrel.And(all).ToSql()
|
||||
}
|
||||
|
||||
func (all All) MarshalJSON() ([]byte, error) {
|
||||
return marshalConjunction("all", all)
|
||||
}
|
||||
|
||||
func (all All) ChildPlaylistIds() (ids []string) {
|
||||
return extractPlaylistIds(all)
|
||||
}
|
||||
|
||||
type (
|
||||
Any squirrel.Or
|
||||
Or = Any
|
||||
)
|
||||
|
||||
func (any Any) ToSql() (sql string, args []any, err error) {
|
||||
return squirrel.Or(any).ToSql()
|
||||
}
|
||||
|
||||
func (any Any) MarshalJSON() ([]byte, error) {
|
||||
return marshalConjunction("any", any)
|
||||
}
|
||||
|
||||
func (any Any) ChildPlaylistIds() (ids []string) {
|
||||
return extractPlaylistIds(any)
|
||||
}
|
||||
|
||||
type Is squirrel.Eq
|
||||
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 Is) MarshalJSON() ([]byte, error) {
|
||||
return marshalExpression("is", is)
|
||||
}
|
||||
|
||||
type IsNot squirrel.NotEq
|
||||
|
||||
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 (in IsNot) MarshalJSON() ([]byte, error) {
|
||||
return marshalExpression("isNot", in)
|
||||
}
|
||||
|
||||
type Gt squirrel.Gt
|
||||
|
||||
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 Gt) MarshalJSON() ([]byte, error) {
|
||||
return marshalExpression("gt", gt)
|
||||
}
|
||||
|
||||
type Lt squirrel.Lt
|
||||
|
||||
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 Lt) MarshalJSON() ([]byte, error) {
|
||||
return marshalExpression("lt", lt)
|
||||
}
|
||||
|
||||
type Before squirrel.Lt
|
||||
|
||||
func (bf Before) ToSql() (sql string, args []any, err error) {
|
||||
return Lt(bf).ToSql()
|
||||
}
|
||||
|
||||
func (bf Before) MarshalJSON() ([]byte, error) {
|
||||
return marshalExpression("before", bf)
|
||||
}
|
||||
|
||||
type After Gt
|
||||
|
||||
func (af After) ToSql() (sql string, args []any, err error) {
|
||||
return Gt(af).ToSql()
|
||||
}
|
||||
|
||||
func (af After) MarshalJSON() ([]byte, error) {
|
||||
return marshalExpression("after", af)
|
||||
}
|
||||
|
||||
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 (ct Contains) MarshalJSON() ([]byte, error) {
|
||||
return marshalExpression("contains", ct)
|
||||
}
|
||||
|
||||
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 (nct NotContains) MarshalJSON() ([]byte, error) {
|
||||
return marshalExpression("notContains", nct)
|
||||
}
|
||||
|
||||
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 (sw StartsWith) MarshalJSON() ([]byte, error) {
|
||||
return marshalExpression("startsWith", sw)
|
||||
}
|
||||
|
||||
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 (sw EndsWith) MarshalJSON() ([]byte, error) {
|
||||
return marshalExpression("endsWith", sw)
|
||||
}
|
||||
|
||||
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 (itr InTheRange) MarshalJSON() ([]byte, error) {
|
||||
return marshalExpression("inTheRange", itr)
|
||||
}
|
||||
|
||||
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 (itl InTheLast) MarshalJSON() ([]byte, error) {
|
||||
return marshalExpression("inTheLast", itl)
|
||||
}
|
||||
|
||||
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 (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 (ipl InPlaylist) MarshalJSON() ([]byte, error) {
|
||||
return marshalExpression("inPlaylist", ipl)
|
||||
}
|
||||
|
||||
type NotInPlaylist map[string]any
|
||||
|
||||
func (ipl NotInPlaylist) ToSql() (sql string, args []any, err error) {
|
||||
return inList(ipl, true)
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
switch rule := inputRule.(type) {
|
||||
case Any:
|
||||
for _, rules := range rule {
|
||||
ids = append(ids, extractPlaylistIds(rules)...)
|
||||
}
|
||||
case All:
|
||||
for _, rules := range rule {
|
||||
ids = append(ids, extractPlaylistIds(rules)...)
|
||||
}
|
||||
case InPlaylist:
|
||||
if id, ok = rule["id"].(string); ok {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
case NotInPlaylist:
|
||||
if id, ok = rule["id"].(string); ok {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
192
model/criteria/operators_test.go
Normal file
192
model/criteria/operators_test.go
Normal file
@@ -0,0 +1,192 @@
|
||||
package criteria_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
. "github.com/navidrome/navidrome/model/criteria"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
"github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = BeforeSuite(func() {
|
||||
AddRoles([]string{"artist", "composer"})
|
||||
AddTagNames([]string{"genre"})
|
||||
AddNumericTags([]string{"rate"})
|
||||
})
|
||||
|
||||
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())),
|
||||
|
||||
// Tag tests
|
||||
Entry("tag is [string]", Is{"genre": "Rock"}, "exists (select 1 from json_tree(tags, '$.genre') where key='value' and value = ?)", "Rock"),
|
||||
Entry("tag isNot [string]", IsNot{"genre": "Rock"}, "not exists (select 1 from json_tree(tags, '$.genre') where key='value' and value = ?)", "Rock"),
|
||||
Entry("tag gt", Gt{"genre": "A"}, "exists (select 1 from json_tree(tags, '$.genre') where key='value' and value > ?)", "A"),
|
||||
Entry("tag lt", Lt{"genre": "Z"}, "exists (select 1 from json_tree(tags, '$.genre') where key='value' and value < ?)", "Z"),
|
||||
Entry("tag contains", Contains{"genre": "Rock"}, "exists (select 1 from json_tree(tags, '$.genre') where key='value' and value LIKE ?)", "%Rock%"),
|
||||
Entry("tag not contains", NotContains{"genre": "Rock"}, "not exists (select 1 from json_tree(tags, '$.genre') where key='value' and value LIKE ?)", "%Rock%"),
|
||||
Entry("tag startsWith", StartsWith{"genre": "Soft"}, "exists (select 1 from json_tree(tags, '$.genre') where key='value' and value LIKE ?)", "Soft%"),
|
||||
Entry("tag endsWith", EndsWith{"genre": "Rock"}, "exists (select 1 from json_tree(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(participants, '$.artist') where key='name' and value = ?)", "u2"),
|
||||
Entry("role isNot [string]", IsNot{"artist": "u2"}, "not exists (select 1 from json_tree(participants, '$.artist') where key='name' and value = ?)", "u2"),
|
||||
Entry("role contains [string]", Contains{"artist": "u2"}, "exists (select 1 from json_tree(participants, '$.artist') where key='name' and value LIKE ?)", "%u2%"),
|
||||
Entry("role not contains [string]", NotContains{"artist": "u2"}, "not exists (select 1 from json_tree(participants, '$.artist') where key='name' and value LIKE ?)", "%u2%"),
|
||||
Entry("role startsWith [string]", StartsWith{"composer": "John"}, "exists (select 1 from json_tree(participants, '$.composer') where key='name' and value LIKE ?)", "John%"),
|
||||
Entry("role endsWith [string]", EndsWith{"composer": "Lennon"}, "exists (select 1 from json_tree(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(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(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(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(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(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(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(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("JSON Marshaling",
|
||||
func(op Expression, jsonString string) {
|
||||
obj := And{op}
|
||||
newJs, err := json.Marshal(obj)
|
||||
gomega.Expect(err).ToNot(gomega.HaveOccurred())
|
||||
gomega.Expect(string(newJs)).To(gomega.Equal(fmt.Sprintf(`{"all":[%s]}`, jsonString)))
|
||||
|
||||
var unmarshalObj UnmarshalConjunctionType
|
||||
js := "[" + jsonString + "]"
|
||||
err = json.Unmarshal([]byte(js), &unmarshalObj)
|
||||
gomega.Expect(err).ToNot(gomega.HaveOccurred())
|
||||
gomega.Expect(unmarshalObj[0]).To(gomega.Equal(op))
|
||||
},
|
||||
Entry("is [string]", Is{"title": "Low Rider"}, `{"is":{"title":"Low Rider"}}`),
|
||||
Entry("is [bool]", Is{"loved": false}, `{"is":{"loved":false}}`),
|
||||
Entry("isNot", IsNot{"title": "Low Rider"}, `{"isNot":{"title":"Low Rider"}}`),
|
||||
Entry("gt", Gt{"playCount": 10.0}, `{"gt":{"playCount":10}}`),
|
||||
Entry("lt", Lt{"playCount": 10.0}, `{"lt":{"playCount":10}}`),
|
||||
Entry("contains", Contains{"title": "Low Rider"}, `{"contains":{"title":"Low Rider"}}`),
|
||||
Entry("notContains", NotContains{"title": "Low Rider"}, `{"notContains":{"title":"Low Rider"}}`),
|
||||
Entry("startsWith", StartsWith{"title": "Low Rider"}, `{"startsWith":{"title":"Low Rider"}}`),
|
||||
Entry("endsWith", EndsWith{"title": "Low Rider"}, `{"endsWith":{"title":"Low Rider"}}`),
|
||||
Entry("inTheRange [number]", InTheRange{"year": []any{1980.0, 1990.0}}, `{"inTheRange":{"year":[1980,1990]}}`),
|
||||
Entry("inTheRange [date]", InTheRange{"lastPlayed": []any{"2021-10-01", "2021-11-01"}}, `{"inTheRange":{"lastPlayed":["2021-10-01","2021-11-01"]}}`),
|
||||
Entry("before", Before{"lastPlayed": "2021-10-01"}, `{"before":{"lastPlayed":"2021-10-01"}}`),
|
||||
Entry("after", After{"lastPlayed": "2021-10-01"}, `{"after":{"lastPlayed":"2021-10-01"}}`),
|
||||
Entry("inTheLast", InTheLast{"lastPlayed": 30.0}, `{"inTheLast":{"lastPlayed":30}}`),
|
||||
Entry("notInTheLast", NotInTheLast{"lastPlayed": 30.0}, `{"notInTheLast":{"lastPlayed":30}}`),
|
||||
Entry("inPlaylist", InPlaylist{"id": "deadbeef-dead-beef"}, `{"inPlaylist":{"id":"deadbeef-dead-beef"}}`),
|
||||
Entry("notInPlaylist", NotInPlaylist{"id": "deadbeef-dead-beef"}, `{"notInPlaylist":{"id":"deadbeef-dead-beef"}}`),
|
||||
)
|
||||
})
|
||||
Reference in New Issue
Block a user