Use new Criteria and remove SmartPlaylist struct

This commit is contained in:
Deluan 2021-10-21 22:15:05 -04:00 committed by Deluan Quintão
parent 3972616585
commit 6a550dab77
9 changed files with 43 additions and 490 deletions

View File

@ -14,6 +14,7 @@ import (
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/criteria"
"github.com/navidrome/navidrome/model/request"
)
@ -96,9 +97,10 @@ func (s *playlists) parseNSP(ctx context.Context, pls *model.Playlist, file io.R
return nil, err
}
pls.Rules = &model.SmartPlaylist{}
pls.Rules = &criteria.Criteria{}
err = json.Unmarshal(content, pls.Rules)
if err != nil {
log.Error(ctx, "Error parsing SmartPlaylist", "playlist", pls.Name, err)
return nil, err
}
return pls, nil

View File

@ -3,6 +3,7 @@ package criteria
import (
"encoding/json"
"strings"
"github.com/Masterminds/squirrel"
)
@ -13,10 +14,18 @@ type Criteria struct {
Expression
Sort string
Order string
Max int
Limit int
Offset int
}
func (c Criteria) OrderBy() string {
f := fieldMap[strings.ToLower(c.Sort)]
if c.Order != "" {
f = f + " " + c.Order
}
return f
}
func (c Criteria) ToSql() (sql string, args []interface{}, err error) {
return c.Expression.ToSql()
}
@ -27,12 +36,12 @@ func (c Criteria) MarshalJSON() ([]byte, error) {
Any []Expression `json:"any,omitempty"`
Sort string `json:"sort"`
Order string `json:"order,omitempty"`
Max int `json:"max,omitempty"`
Offset int `json:"offset"`
Limit int `json:"limit"`
Offset int `json:"offset,omitempty"`
}{
Sort: c.Sort,
Order: c.Order,
Max: c.Max,
Limit: c.Limit,
Offset: c.Offset,
}
switch rules := c.Expression.(type) {
@ -48,11 +57,11 @@ func (c Criteria) MarshalJSON() ([]byte, error) {
func (c *Criteria) UnmarshalJSON(data []byte) error {
var aux struct {
All unmarshalConjunctionType `json:"all,omitempty"`
Any unmarshalConjunctionType `json:"any,omitempty"`
All unmarshalConjunctionType `json:"all"`
Any unmarshalConjunctionType `json:"any"`
Sort string `json:"sort"`
Order string `json:"order,omitempty"`
Max int `json:"max,omitempty"`
Order string `json:"order"`
Limit int `json:"limit"`
Offset int `json:"offset"`
}
if err := json.Unmarshal(data, &aux); err != nil {
@ -65,7 +74,7 @@ func (c *Criteria) UnmarshalJSON(data []byte) error {
}
c.Sort = aux.Sort
c.Order = aux.Order
c.Max = aux.Max
c.Limit = aux.Limit
c.Offset = aux.Offset
return nil
}

View File

@ -5,13 +5,11 @@ import (
_ "github.com/mattn/go-sqlite3"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/tests"
. "github.com/onsi/ginkgo"
"github.com/onsi/gomega"
)
func TestCriteria(t *testing.T) {
tests.Init(t, true)
log.SetLevel(log.LevelCritical)
gomega.RegisterFailHandler(Fail)
RunSpecs(t, "Criteria Suite")

View File

@ -27,7 +27,7 @@ var _ = Describe("Criteria", func() {
},
Sort: "title",
Order: "asc",
Max: 20,
Limit: 20,
Offset: 10,
}
var b bytes.Buffer
@ -49,7 +49,7 @@ var _ = Describe("Criteria", func() {
],
"sort": "title",
"order": "asc",
"max": 20,
"limit": 20,
"offset": 10
}
`))

View File

@ -35,7 +35,7 @@ var _ = Describe("Operators", func() {
Entry("after", After{"lastPlayed": rangeStart}, "annotation.play_date > ?", rangeStart),
// TODO These may be flaky
Entry("inTheLast", InTheLast{"lastPlayed": 30}, "annotation.play_date > ?", startOfPeriod(30, time.Now())),
Entry("notInPeriod", NotInTheLast{"lastPlayed": 30}, "(annotation.play_date < ? OR annotation.play_date IS NULL)", startOfPeriod(30, time.Now())),
Entry("notInTheLast", NotInTheLast{"lastPlayed": 30}, "(annotation.play_date < ? OR annotation.play_date IS NULL)", startOfPeriod(30, time.Now())),
)
DescribeTable("JSON Conversion",

View File

@ -4,6 +4,7 @@ import (
"strconv"
"time"
"github.com/navidrome/navidrome/model/criteria"
"github.com/navidrome/navidrome/utils"
)
@ -23,12 +24,12 @@ type Playlist struct {
UpdatedAt time.Time `structs:"updated_at" json:"updatedAt"`
// SmartPlaylist attributes
Rules *SmartPlaylist `structs:"-" json:"rules"`
EvaluatedAt time.Time `structs:"evaluated_at" json:"evaluatedAt"`
Rules *criteria.Criteria `structs:"-" json:"rules"`
EvaluatedAt time.Time `structs:"evaluated_at" json:"evaluatedAt"`
}
func (pls Playlist) IsSmartPlaylist() bool {
return pls.Rules != nil && pls.Rules.Combinator != ""
return pls.Rules != nil && pls.Rules.Expression != nil
}
func (pls Playlist) MediaFiles() MediaFiles {

View File

@ -11,6 +11,7 @@ import (
"github.com/deluan/rest"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/criteria"
"github.com/navidrome/navidrome/utils"
)
@ -143,12 +144,12 @@ func (r *playlistRepository) findBy(sql Sqlizer) (*model.Playlist, error) {
func (r *playlistRepository) toModel(pls dbPlaylist) (*model.Playlist, error) {
var err error
if strings.TrimSpace(pls.RawRules) != "" {
r := model.SmartPlaylist{}
err = json.Unmarshal([]byte(pls.RawRules), &r)
var c criteria.Criteria
err = json.Unmarshal([]byte(pls.RawRules), &c)
if err != nil {
return nil, err
}
pls.Playlist.Rules = &r
pls.Playlist.Rules = &c
} else {
pls.Playlist.Rules = nil
}
@ -190,13 +191,13 @@ func (r *playlistRepository) refreshSmartPlaylist(pls *model.Playlist) bool {
}
// Re-populate playlist based on Smart Playlist criteria
sp := smartPlaylist(*pls.Rules)
sql := Select("row_number() over (order by "+sp.OrderBy()+") as id", "'"+pls.ID+"' as playlist_id", "media_file.id as media_file_id").
rules := *pls.Rules
sql := Select("row_number() over (order by "+rules.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 = '" + userId(r.ctx) + "')")
sql = sp.AddCriteria(sql)
sql = r.addCriteria(sql, rules)
insSql := Insert("playlist_tracks").Columns("id", "playlist_id", "media_file_id").Select(sql)
c, err := r.executeSQL(insSql)
if err != nil {
@ -224,6 +225,14 @@ func (r *playlistRepository) refreshSmartPlaylist(pls *model.Playlist) bool {
return true
}
func (r *playlistRepository) addCriteria(sql SelectBuilder, c criteria.Criteria) SelectBuilder {
sql = sql.Where(c.ToSql()).Limit(uint64(c.Limit)).Offset(uint64(c.Offset))
if order := c.OrderBy(); order != "" {
sql = sql.OrderBy(order)
}
return sql
}
func (r *playlistRepository) updateTracks(id string, tracks model.MediaFiles) error {
ids := make([]string, len(tracks))
for i := range tracks {

View File

@ -1,288 +0,0 @@
package persistence
import (
"errors"
"fmt"
"reflect"
"strconv"
"strings"
"time"
. "github.com/Masterminds/squirrel"
"github.com/navidrome/navidrome/model"
)
//{
//"combinator": "and",
//"rules": [
// {"field": "lastPlayed", "operator": "in the last", "value": "30"}
//],
//"order": "lastPlayed desc",
//"limit": 10
//}
type smartPlaylist model.SmartPlaylist
func (sp smartPlaylist) AddCriteria(sql SelectBuilder) SelectBuilder {
sql = sql.Where(RuleGroup(sp.RuleGroup)).Limit(uint64(sp.Limit))
if order := sp.OrderBy(); order != "" {
sql = sql.OrderBy(order)
}
return sql
}
func (sp smartPlaylist) OrderBy() string {
order := strings.ToLower(sp.Order)
for f, fieldDef := range fieldMap {
if strings.HasPrefix(order, f) {
order = strings.Replace(order, f, fieldDef.dbField, 1)
}
}
return order
}
type fieldDef struct {
dbField string
ruleType reflect.Type
}
var fieldMap = map[string]*fieldDef{
"title": {"media_file.title", stringRuleType},
"album": {"media_file.album", stringRuleType},
"artist": {"media_file.artist", stringRuleType},
"albumartist": {"media_file.album_artist", stringRuleType},
"albumartwork": {"media_file.has_cover_art", stringRuleType},
"tracknumber": {"media_file.track_number", numberRuleType},
"discnumber": {"media_file.disc_number", numberRuleType},
"year": {"media_file.year", numberRuleType},
"size": {"media_file.size", numberRuleType},
"compilation": {"media_file.compilation", boolRuleType},
"dateadded": {"media_file.created_at", dateRuleType},
"datemodified": {"media_file.updated_at", dateRuleType},
"discsubtitle": {"media_file.disc_subtitle", stringRuleType},
"comment": {"media_file.comment", stringRuleType},
"lyrics": {"media_file.lyrics", stringRuleType},
"sorttitle": {"media_file.sort_title", stringRuleType},
"sortalbum": {"media_file.sort_album_name", stringRuleType},
"sortartist": {"media_file.sort_artist_name", stringRuleType},
"sortalbumartist": {"media_file.sort_album_artist_name", stringRuleType},
"albumtype": {"media_file.mbz_album_type", stringRuleType},
"albumcomment": {"media_file.mbz_album_comment", stringRuleType},
"catalognumber": {"media_file.catalog_num", stringRuleType},
"filepath": {"media_file.path", stringRuleType},
"filetype": {"media_file.suffix", stringRuleType},
"duration": {"media_file.duration", numberRuleType},
"bitrate": {"media_file.bit_rate", numberRuleType},
"bpm": {"media_file.bpm", numberRuleType},
"channels": {"media_file.channels", numberRuleType},
"genre": {"genre.name", stringRuleType},
"loved": {"annotation.starred", boolRuleType},
"lastplayed": {"annotation.play_date", dateRuleType},
"playcount": {"annotation.play_count", numberRuleType},
"rating": {"annotation.rating", numberRuleType},
}
var stringRuleType = reflect.TypeOf(stringRule{})
type stringRule model.Rule
func (r stringRule) ToSql() (sql string, args []interface{}, err error) {
var sq Sqlizer
switch r.Operator {
case "is":
sq = Eq{r.Field: r.Value}
case "is not":
sq = NotEq{r.Field: r.Value}
case "contains":
sq = ILike{r.Field: fmt.Sprintf("%%%s%%", r.Value)}
case "does not contains":
sq = NotILike{r.Field: fmt.Sprintf("%%%s%%", r.Value)}
case "begins with":
sq = ILike{r.Field: fmt.Sprintf("%s%%", r.Value)}
case "ends with":
sq = ILike{r.Field: fmt.Sprintf("%%%s", r.Value)}
default:
return "", nil, errors.New("operator not supported: " + r.Operator)
}
return sq.ToSql()
}
var numberRuleType = reflect.TypeOf(numberRule{})
type numberRule model.Rule
func (r numberRule) ToSql() (sql string, args []interface{}, err error) {
var sq Sqlizer
switch r.Operator {
case "is":
sq = Eq{r.Field: r.Value}
case "is not":
sq = NotEq{r.Field: r.Value}
case "is greater than":
sq = Gt{r.Field: r.Value}
case "is less than":
sq = Lt{r.Field: r.Value}
case "is in the range":
s := reflect.ValueOf(r.Value)
if s.Kind() != reflect.Slice || s.Len() != 2 {
return "", nil, fmt.Errorf("invalid range for 'in' operator: %s", r.Value)
}
sq = And{
GtOrEq{r.Field: s.Index(0).Interface()},
LtOrEq{r.Field: s.Index(1).Interface()},
}
default:
return "", nil, errors.New("operator not supported: " + r.Operator)
}
return sq.ToSql()
}
var dateRuleType = reflect.TypeOf(dateRule{})
type dateRule model.Rule
func (r dateRule) ToSql() (string, []interface{}, error) {
var date time.Time
var err error
var sq Sqlizer
switch r.Operator {
case "is":
date, err = r.parseDate(r.Value)
sq = Eq{r.Field: date}
case "is not":
date, err = r.parseDate(r.Value)
sq = NotEq{r.Field: date}
case "is before":
date, err = r.parseDate(r.Value)
sq = Lt{r.Field: date}
case "is after":
date, err = r.parseDate(r.Value)
sq = Gt{r.Field: date}
case "is in the range":
var dates []time.Time
if dates, err = r.parseDates(); err == nil {
sq = And{GtOrEq{r.Field: dates[0]}, LtOrEq{r.Field: dates[1]}}
}
case "in the last":
sq, err = r.inTheLast(false)
case "not in the last":
sq, err = r.inTheLast(true)
default:
err = errors.New("operator not supported: " + r.Operator)
}
if err != nil {
return "", nil, err
}
return sq.ToSql()
}
func (r dateRule) inTheLast(invert bool) (Sqlizer, error) {
str := fmt.Sprintf("%v", r.Value)
v, err := strconv.ParseInt(str, 10, 64)
if err != nil {
return nil, err
}
period := time.Now().Add(time.Duration(-24*v) * time.Hour)
if invert {
return Or{
Lt{r.Field: period},
Eq{r.Field: nil},
}, nil
}
return Gt{r.Field: period}, nil
}
func (r dateRule) parseDate(date interface{}) (time.Time, error) {
input, ok := date.(string)
if !ok {
return time.Time{}, fmt.Errorf("invalid date: %v", date)
}
d, err := time.Parse("2006-01-02", input)
if err != nil {
return time.Time{}, fmt.Errorf("invalid date: %v", date)
}
return d, nil
}
func (r dateRule) parseDates() ([]time.Time, error) {
input, ok := r.Value.([]string)
if !ok {
return nil, fmt.Errorf("invalid date range: %s", r.Value)
}
var dates []time.Time
for _, s := range input {
d, err := r.parseDate(s)
if err != nil {
return nil, fmt.Errorf("invalid date '%v' in range %v", s, input)
}
dates = append(dates, d)
}
if len(dates) != 2 {
return nil, fmt.Errorf("not a valid date range: %s", r.Value)
}
return dates, nil
}
var boolRuleType = reflect.TypeOf(boolRule{})
type boolRule model.Rule
func (r boolRule) ToSql() (sql string, args []interface{}, err error) {
var sq Sqlizer
switch r.Operator {
case "is true":
sq = Eq{r.Field: true}
case "is false":
sq = Eq{r.Field: false}
default:
return "", nil, errors.New("operator not supported: " + r.Operator)
}
return sq.ToSql()
}
type RuleGroup model.RuleGroup
func (rg RuleGroup) ToSql() (sql string, args []interface{}, err error) {
var sq []Sqlizer
for _, r := range rg.Rules {
switch rr := r.(type) {
case model.Rule:
sq = append(sq, rg.ruleToSqlizer(rr))
case model.RuleGroup:
sq = append(sq, RuleGroup(rr))
}
}
var group Sqlizer
if strings.ToLower(rg.Combinator) == "and" {
group = And(sq)
} else {
group = Or(sq)
}
return group.ToSql()
}
type errorSqlizer string
func (e errorSqlizer) ToSql() (sql string, args []interface{}, err error) {
return "", nil, errors.New(string(e))
}
func (rg RuleGroup) ruleToSqlizer(r model.Rule) Sqlizer {
ruleDef := fieldMap[strings.ToLower(r.Field)]
if ruleDef == nil {
return errorSqlizer(fmt.Sprintf("invalid smart playlist field '%s'", r.Field))
}
r.Field = ruleDef.dbField
r.Operator = strings.ToLower(r.Operator)
switch ruleDef.ruleType {
case stringRuleType:
return stringRule(r)
case numberRuleType:
return numberRule(r)
case boolRuleType:
return boolRule(r)
case dateRuleType:
return dateRule(r)
default:
return errorSqlizer("invalid smart playlist rule type" + ruleDef.ruleType.String())
}
}

View File

@ -1,178 +0,0 @@
package persistence
import (
"time"
"github.com/Masterminds/squirrel"
"github.com/navidrome/navidrome/model"
. "github.com/onsi/ginkgo"
. "github.com/onsi/ginkgo/extensions/table"
. "github.com/onsi/gomega"
)
var _ = Describe("smartPlaylist", func() {
var pls smartPlaylist
Describe("AddCriteria", func() {
BeforeEach(func() {
sp := model.SmartPlaylist{
RuleGroup: model.RuleGroup{
Combinator: "and", Rules: model.Rules{
model.Rule{Field: "title", Operator: "contains", Value: "love"},
model.Rule{Field: "year", Operator: "is in the range", Value: []int{1980, 1989}},
model.Rule{Field: "loved", Operator: "is true"},
model.Rule{Field: "lastPlayed", Operator: "in the last", Value: "30"},
model.RuleGroup{
Combinator: "or",
Rules: model.Rules{
model.Rule{Field: "artist", Operator: "is not", Value: "zé"},
model.Rule{Field: "album", Operator: "is", Value: "4"},
},
},
}},
Order: "artist asc",
Limit: 100,
}
pls = smartPlaylist(sp)
})
It("returns a proper SQL query", func() {
sel := pls.AddCriteria(squirrel.Select("media_file").Columns("*"))
sql, args, err := sel.ToSql()
Expect(err).ToNot(HaveOccurred())
Expect(sql).To(Equal("SELECT media_file, * WHERE (media_file.title ILIKE ? AND (media_file.year >= ? AND media_file.year <= ?) AND annotation.starred = ? AND annotation.play_date > ? AND (media_file.artist <> ? OR media_file.album = ?)) ORDER BY media_file.artist asc LIMIT 100"))
lastMonth := time.Now().Add(-30 * 24 * time.Hour)
Expect(args).To(ConsistOf("%love%", 1980, 1989, true, BeTemporally("~", lastMonth, time.Second), "zé", "4"))
})
It("returns an error if field is invalid", func() {
r := pls.Rules[0].(model.Rule)
r.Field = "INVALID"
pls.Rules[0] = r
sel := pls.AddCriteria(squirrel.Select("media_file").Columns("*"))
_, _, err := sel.ToSql()
Expect(err).To(MatchError("invalid smart playlist field 'INVALID'"))
})
})
Describe("fieldMap", func() {
It("includes all possible fields", func() {
for _, field := range model.SmartPlaylistFields {
Expect(fieldMap).To(HaveKey(field))
}
})
It("does not have extra fields", func() {
for field := range fieldMap {
Expect(model.SmartPlaylistFields).To(ContainElement(field))
}
})
})
Describe("stringRule", func() {
DescribeTable("stringRule",
func(operator, expectedSql, expectedValue string) {
r := stringRule{Field: "title", Operator: operator, Value: "value"}
sql, args, err := r.ToSql()
Expect(err).ToNot(HaveOccurred())
Expect(sql).To(Equal(expectedSql))
Expect(args).To(ConsistOf(expectedValue))
},
Entry("is", "is", "title = ?", "value"),
Entry("is not", "is not", "title <> ?", "value"),
Entry("contains", "contains", "title ILIKE ?", "%value%"),
Entry("does not contains", "does not contains", "title NOT ILIKE ?", "%value%"),
Entry("begins with", "begins with", "title ILIKE ?", "value%"),
Entry("ends with", "ends with", "title ILIKE ?", "%value"),
)
})
Describe("numberRule", func() {
DescribeTable("operators",
func(operator, expectedSql string, expectedValue int) {
r := numberRule{Field: "year", Operator: operator, Value: 1985}
sql, args, err := r.ToSql()
Expect(err).ToNot(HaveOccurred())
Expect(sql).To(Equal(expectedSql))
Expect(args).To(ConsistOf(expectedValue))
},
Entry("is", "is", "year = ?", 1985),
Entry("is not", "is not", "year <> ?", 1985),
Entry("is greater than", "is greater than", "year > ?", 1985),
Entry("is less than", "is less than", "year < ?", 1985),
)
It("implements the 'is in the range' operator", func() {
r := numberRule{Field: "year", Operator: "is in the range", Value: []int{1981, 1990}}
sql, args, err := r.ToSql()
Expect(err).ToNot(HaveOccurred())
Expect(sql).To(Equal("(year >= ? AND year <= ?)"))
Expect(args).To(ConsistOf(1981, 1990))
})
})
Describe("dateRule", func() {
delta := 30 * time.Hour // Must be large to account for the hours of the day
dateStr := time.Now().Format("2006-01-02")
date, _ := time.Parse("2006-01-02", dateStr)
DescribeTable("simple operators",
func(operator, expectedSql string, expectedValue time.Time) {
r := dateRule{Field: "lastPlayed", Operator: operator, Value: dateStr}
sql, args, err := r.ToSql()
Expect(err).ToNot(HaveOccurred())
Expect(sql).To(Equal(expectedSql))
Expect(args).To(ConsistOf(expectedValue))
},
Entry("is", "is", "lastPlayed = ?", date),
Entry("is not", "is not", "lastPlayed <> ?", date),
Entry("is before", "is before", "lastPlayed < ?", date),
Entry("is after", "is after", "lastPlayed > ?", date),
)
DescribeTable("period operators",
func(operator, expectedSql string, expectedValue time.Time) {
r := dateRule{Field: "lastPlayed", Operator: operator, Value: 90}
sql, args, err := r.ToSql()
Expect(err).ToNot(HaveOccurred())
Expect(sql).To(Equal(expectedSql))
Expect(args).To(ConsistOf(BeTemporally("~", expectedValue, delta)))
},
Entry("in the last", "in the last", "lastPlayed > ?", date.Add(-90*24*time.Hour)),
Entry("not in the last", "not in the last", "(lastPlayed < ? OR lastPlayed IS NULL)", date.Add(-90*24*time.Hour)),
)
It("accepts string as the 'in the last' operator value", func() {
r := dateRule{Field: "lastPlayed", Operator: "in the last", Value: "90"}
_, args, _ := r.ToSql()
Expect(args).To(ConsistOf(BeTemporally("~", date.Add(-90*24*time.Hour), delta)))
})
It("implements the 'is in the range' operator", func() {
date2Str := time.Now().Add(48 * time.Hour).Format("2006-01-02")
date2, _ := time.Parse("2006-01-02", date2Str)
r := dateRule{Field: "lastPlayed", Operator: "is in the range", Value: []string{date2Str, dateStr}}
sql, args, err := r.ToSql()
Expect(err).ToNot(HaveOccurred())
Expect(sql).To(Equal("(lastPlayed >= ? AND lastPlayed <= ?)"))
Expect(args).To(ConsistOf(BeTemporally("~", date2, 24*time.Hour), BeTemporally("~", date, delta)))
})
It("returns error if date is invalid", func() {
r := dateRule{Field: "lastPlayed", Operator: "is", Value: "INVALID"}
_, _, err := r.ToSql()
Expect(err).To(MatchError("invalid date: INVALID"))
})
})
Describe("boolRule", func() {
DescribeTable("operators",
func(operator, expectedSql string, expectedValue ...interface{}) {
r := boolRule{Field: "loved", Operator: operator}
sql, args, err := r.ToSql()
Expect(err).ToNot(HaveOccurred())
Expect(sql).To(Equal(expectedSql))
Expect(args).To(ConsistOf(expectedValue...))
},
Entry("is true", "is true", "loved = ?", true),
Entry("is false", "is false", "loved = ?", false),
)
})
})