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
+11 -14
View File
@@ -122,27 +122,24 @@ func mapExpr(expr squirrel.Sqlizer, negate bool, exprFunc func(string, squirrel.
log.Fatal(fmt.Sprintf("expr is not a map-based operator: %T", expr))
}
// Extract into a generic map
// 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
m := make(map[string]any, rv.Len())
var v any
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()
v = 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{})
}
// 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)
// 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)
return exprFunc(k, newExpr, negate)
}
// mapTagExpr maps a normal field expression to a tag expression.