feat(server): add percentage-based limits to smart playlists (#5144)

* feat(playlists): add percentage-based limits to smart playlists

Add a new `limitPercent` JSON field to Criteria that allows smart playlist
limits to be expressed as a percentage of matching tracks rather than a
fixed number. For example, a playlist matching 450 songs with a 10% limit
returns 45 songs, scaling dynamically as the library grows.

When `limitPercent` is set, refreshSmartPlaylist runs a COUNT query first
to determine the total matching tracks, then resolves the percentage to an
absolute LIMIT before executing the main query. The fixed `limit` field
takes precedence when both are set. Values are clamped to [0, 100] during
JSON unmarshaling.

No database migration is needed since rules are stored as a JSON string.

* fix(criteria): validate percentage limit range in IsPercentageLimit method

Signed-off-by: Deluan <deluan@navidrome.org>

* fix(criteria): ensure idempotency of ToSql method for expressions

Signed-off-by: Deluan <deluan@navidrome.org>

---------

Signed-off-by: Deluan <deluan@navidrome.org>
This commit is contained in:
Deluan Quintão
2026-03-04 22:42:49 -05:00
committed by GitHub
parent f03ca44a8e
commit 11e4aaed1b
6 changed files with 296 additions and 46 deletions
+15
View File
@@ -178,6 +178,21 @@ var _ = Describe("Operators", func() {
})
})
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}