From eee260faa847b0079e380c1c7c9ef931885ced7a Mon Sep 17 00:00:00 2001 From: Valeri Sokolov Date: Sat, 24 Jan 2026 11:41:26 +0100 Subject: [PATCH] feat(criteria): make album ratings available to smart playlist queries Expose an "albumrating" field mapping to album annotations. Signed-off-by: Valeri Sokolov --- model/criteria/criteria_test.go | 8 +++++--- model/criteria/fields.go | 3 ++- model/criteria/operators_test.go | 28 ++++++++++++++-------------- persistence/playlist_repository.go | 7 ++++++- 4 files changed, 27 insertions(+), 19 deletions(-) diff --git a/model/criteria/criteria_test.go b/model/criteria/criteria_test.go index 032ead5c8..fc1604ec0 100644 --- a/model/criteria/criteria_test.go +++ b/model/criteria/criteria_test.go @@ -27,6 +27,7 @@ var _ = Describe("Criteria", func() { StartsWith{"comment": "this"}, InTheRange{"year": []int{1980, 1990}}, IsNot{"genre": "Rock"}, + Gt{"albumrating": 3}, }, }, Sort: "title", @@ -48,7 +49,8 @@ var _ = Describe("Criteria", func() { { "all": [ { "startsWith": {"comment": "this"} }, { "inTheRange": {"year":[1980,1990]} }, - { "isNot": { "genre": "Rock" }} + { "isNot": { "genre": "Rock" }}, + { "gt": { "albumrating": 3 } } ] } ], @@ -70,8 +72,8 @@ var _ = Describe("Criteria", func() { `(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")) + `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) diff --git a/model/criteria/fields.go b/model/criteria/fields.go index 5381ae597..6c803428e 100644 --- a/model/criteria/fields.go +++ b/model/criteria/fields.go @@ -47,6 +47,7 @@ var fieldMap = map[string]*mappedField{ "daterated": {field: "annotation.rated_at"}, "playcount": {field: "COALESCE(annotation.play_count, 0)"}, "rating": {field: "COALESCE(annotation.rating, 0)"}, + "albumrating": {field: "COALESCE(album_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"}, @@ -168,7 +169,7 @@ func (e tagCond) ToSql() (string, []any, error) { } } - cond = fmt.Sprintf("exists (select 1 from json_tree(tags, '$.%s') where key='value' and %s)", + 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 diff --git a/model/criteria/operators_test.go b/model/criteria/operators_test.go index 4c1db1303..d1b47ce51 100644 --- a/model/criteria/operators_test.go +++ b/model/criteria/operators_test.go @@ -55,14 +55,14 @@ var _ = Describe("Operators", func() { 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"), + 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(participants, '$.artist') where key='name' and value = ?)", "u2"), @@ -88,7 +88,7 @@ var _ = Describe("Operators", func() { 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(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() { @@ -96,7 +96,7 @@ var _ = Describe("Operators", func() { 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(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() { @@ -110,7 +110,7 @@ var _ = Describe("Operators", func() { 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(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() { @@ -118,7 +118,7 @@ var _ = Describe("Operators", func() { 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(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() { @@ -127,7 +127,7 @@ var _ = Describe("Operators", func() { 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(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() { @@ -136,7 +136,7 @@ var _ = Describe("Operators", func() { 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(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")) }) }) diff --git a/persistence/playlist_repository.go b/persistence/playlist_repository.go index 3fdd19af2..ddefcd7a8 100644 --- a/persistence/playlist_repository.go +++ b/persistence/playlist_repository.go @@ -263,7 +263,12 @@ func (r *playlistRepository) refreshSmartPlaylist(pls *model.Playlist) bool { From("media_file").LeftJoin("annotation on (" + "annotation.item_id = media_file.id" + " AND annotation.item_type = 'media_file'" + - " AND annotation.user_id = '" + usr.ID + "')") + " AND annotation.user_id = '" + usr.ID + "')"). + LeftJoin("album on (album.id = media_file.album_id)"). + LeftJoin("annotation as album_annotation on ("+ + "album_annotation.item_id = album.id" + + " AND album_annotation.item_type = 'album'" + + " AND album_annotation.user_id = '" + usr.ID + "')") // Only include media files from libraries the user has access to sq = r.applyLibraryFilter(sq, "media_file")