2026-04-24 19:03:10 -08:00
|
|
|
package e2e
|
|
|
|
|
|
|
|
|
|
import (
|
2026-04-25 10:59:06 -08:00
|
|
|
"github.com/navidrome/navidrome/tests"
|
2026-04-24 19:03:10 -08:00
|
|
|
. "github.com/onsi/ginkgo/v2"
|
|
|
|
|
. "github.com/onsi/gomega"
|
2026-04-25 10:59:06 -08:00
|
|
|
"github.com/sirupsen/logrus"
|
2026-04-24 19:03:10 -08:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
var _ = Describe("Smart Playlists", func() {
|
|
|
|
|
BeforeEach(func() {
|
|
|
|
|
setupTestDB()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
Describe("String fields", func() {
|
|
|
|
|
It("matches by exact title", func() {
|
|
|
|
|
results := evaluateRule(`{"all":[{"is":{"title":"Something"}}]}`)
|
|
|
|
|
Expect(results).To(ConsistOf("Something"))
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
It("matches by title contains", func() {
|
|
|
|
|
results := evaluateRule(`{"all":[{"contains":{"title":"the"}}]}`)
|
|
|
|
|
Expect(results).To(ConsistOf("Come Together", "All Along the Watchtower", "We Are the Champions"))
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
It("matches by artist startsWith", func() {
|
|
|
|
|
results := evaluateRule(`{"all":[{"startsWith":{"artist":"Led"}}]}`)
|
|
|
|
|
Expect(results).To(ConsistOf("Stairway To Heaven", "Black Dog"))
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
It("matches by title isNot", func() {
|
|
|
|
|
results := evaluateRule(`{"all":[{"isNot":{"title":"Something"}},{"is":{"artist":"The Beatles"}}]}`)
|
|
|
|
|
Expect(results).To(ConsistOf("Come Together"))
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
It("matches by artist endsWith", func() {
|
|
|
|
|
results := evaluateRule(`{"all":[{"endsWith":{"artist":"Davis"}}]}`)
|
|
|
|
|
Expect(results).To(ConsistOf("So What"))
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
Describe("Numeric fields", func() {
|
|
|
|
|
It("matches by year greater than", func() {
|
|
|
|
|
results := evaluateRule(`{"all":[{"gt":{"year":1970}}]}`)
|
|
|
|
|
Expect(results).To(ConsistOf("Stairway To Heaven", "Black Dog", "Bohemian Rhapsody", "We Are the Champions"))
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
It("matches by year less than", func() {
|
|
|
|
|
results := evaluateRule(`{"all":[{"lt":{"year":1969}}]}`)
|
|
|
|
|
Expect(results).To(ConsistOf("So What", "All Along the Watchtower"))
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
It("matches by BPM in range", func() {
|
|
|
|
|
results := evaluateRule(`{"all":[{"inTheRange":{"bpm":[100,130]}}]}`)
|
|
|
|
|
Expect(results).To(ConsistOf("Come Together", "Something", "All Along the Watchtower"))
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
Describe("Boolean fields", func() {
|
|
|
|
|
It("matches compilations", func() {
|
|
|
|
|
results := evaluateRule(`{"all":[{"is":{"compilation":true}}]}`)
|
|
|
|
|
Expect(results).To(ConsistOf("We Are the Champions"))
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
It("matches non-compilations", func() {
|
|
|
|
|
results := evaluateRule(`{"all":[{"is":{"compilation":false}}]}`)
|
|
|
|
|
Expect(results).To(ConsistOf("Come Together", "Something", "Stairway To Heaven", "Black Dog", "So What", "Bohemian Rhapsody", "All Along the Watchtower"))
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
Describe("File type fields", func() {
|
|
|
|
|
It("matches by filetype", func() {
|
|
|
|
|
results := evaluateRule(`{"all":[{"is":{"filetype":"flac"}}]}`)
|
|
|
|
|
Expect(results).To(ConsistOf("Stairway To Heaven", "Black Dog"))
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
Describe("Multi-valued tags", func() {
|
|
|
|
|
It("matches tracks with Blues genre", func() {
|
|
|
|
|
results := evaluateRule(`{"all":[{"is":{"genre":"Blues"}}]}`)
|
|
|
|
|
Expect(results).To(ConsistOf("Come Together", "Black Dog", "All Along the Watchtower"))
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
It("excludes tracks with Rock genre", func() {
|
|
|
|
|
results := evaluateRule(`{"all":[{"isNot":{"genre":"Rock"}}]}`)
|
|
|
|
|
Expect(results).To(ConsistOf("So What"))
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
It("matches genre contains", func() {
|
|
|
|
|
results := evaluateRule(`{"all":[{"contains":{"genre":"ol"}}]}`)
|
|
|
|
|
Expect(results).To(ConsistOf("Stairway To Heaven"))
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
It("matches tracks with Pop genre", func() {
|
|
|
|
|
results := evaluateRule(`{"all":[{"is":{"genre":"Pop"}}]}`)
|
|
|
|
|
Expect(results).To(ConsistOf("We Are the Champions"))
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
It("matches genre startsWith", func() {
|
|
|
|
|
results := evaluateRule(`{"all":[{"startsWith":{"genre":"Ro"}}]}`)
|
|
|
|
|
Expect(results).To(ConsistOf("Come Together", "Something", "Stairway To Heaven", "Black Dog",
|
|
|
|
|
"Bohemian Rhapsody", "All Along the Watchtower", "We Are the Champions"))
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
Describe("Participants", func() {
|
|
|
|
|
It("matches by exact composer", func() {
|
|
|
|
|
results := evaluateRule(`{"all":[{"is":{"composer":"Harrison"}}]}`)
|
|
|
|
|
Expect(results).To(ConsistOf("Something"))
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
It("matches by composer contains", func() {
|
|
|
|
|
results := evaluateRule(`{"all":[{"contains":{"composer":"Plant"}}]}`)
|
|
|
|
|
Expect(results).To(ConsistOf("Stairway To Heaven", "Black Dog"))
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
It("matches by composer isNot", func() {
|
|
|
|
|
results := evaluateRule(`{"all":[{"isNot":{"composer":"Freddie Mercury"}}]}`)
|
|
|
|
|
Expect(results).To(ConsistOf("Come Together", "Something", "Stairway To Heaven", "Black Dog", "So What", "All Along the Watchtower"))
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
It("matches by composer endsWith", func() {
|
|
|
|
|
results := evaluateRule(`{"all":[{"endsWith":{"composer":"Mercury"}}]}`)
|
|
|
|
|
Expect(results).To(ConsistOf("Bohemian Rhapsody", "We Are the Champions"))
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
Describe("Annotations", func() {
|
|
|
|
|
It("matches starred tracks", func() {
|
|
|
|
|
results := evaluateRule(`{"all":[{"is":{"loved":true}}]}`)
|
|
|
|
|
Expect(results).To(ConsistOf("Come Together", "So What"))
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
It("matches unstarred tracks", func() {
|
|
|
|
|
results := evaluateRule(`{"all":[{"is":{"loved":false}}]}`)
|
|
|
|
|
Expect(results).To(ConsistOf("Something", "Stairway To Heaven", "Black Dog", "Bohemian Rhapsody", "All Along the Watchtower", "We Are the Champions"))
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
It("matches by rating greater than", func() {
|
|
|
|
|
results := evaluateRule(`{"all":[{"gt":{"rating":3}}]}`)
|
|
|
|
|
Expect(results).To(ConsistOf("Bohemian Rhapsody"))
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
It("matches by rating greater than or equal via inTheRange", func() {
|
|
|
|
|
results := evaluateRule(`{"all":[{"inTheRange":{"rating":[3,5]}}]}`)
|
|
|
|
|
Expect(results).To(ConsistOf("Stairway To Heaven", "Bohemian Rhapsody"))
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
It("matches by play count greater than", func() {
|
|
|
|
|
results := evaluateRule(`{"all":[{"gt":{"playcount":5}}]}`)
|
|
|
|
|
Expect(results).To(ConsistOf("Come Together"))
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
It("matches by play count greater than zero", func() {
|
|
|
|
|
results := evaluateRule(`{"all":[{"gt":{"playcount":0}}]}`)
|
|
|
|
|
Expect(results).To(ConsistOf("Come Together", "Black Dog"))
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
Describe("Negated string operators", func() {
|
|
|
|
|
It("matches by title notContains", func() {
|
|
|
|
|
results := evaluateRule(`{"all":[{"notContains":{"title":"the"}}]}`)
|
|
|
|
|
Expect(results).To(ConsistOf("Something", "Stairway To Heaven", "Black Dog", "So What", "Bohemian Rhapsody"))
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
Describe("Date/time fields", func() {
|
|
|
|
|
It("matches dateAdded before a far-future date", func() {
|
|
|
|
|
results := evaluateRule(`{"all":[{"before":{"dateadded":"2099-01-01"}}]}`)
|
|
|
|
|
Expect(results).To(ConsistOf("Come Together", "Something", "Stairway To Heaven", "Black Dog",
|
|
|
|
|
"So What", "Bohemian Rhapsody", "All Along the Watchtower", "We Are the Champions"))
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
It("matches lastPlayed inTheLast 1 day", func() {
|
|
|
|
|
results := evaluateRule(`{"all":[{"inTheLast":{"lastplayed":1}}]}`)
|
|
|
|
|
Expect(results).To(ConsistOf("Come Together", "Black Dog"))
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
It("matches lastPlayed notInTheLast (far future)", func() {
|
|
|
|
|
results := evaluateRule(`{"all":[{"notInTheLast":{"lastplayed":99999}}]}`)
|
|
|
|
|
Expect(results).To(ConsistOf("Something", "Stairway To Heaven", "So What",
|
|
|
|
|
"Bohemian Rhapsody", "All Along the Watchtower", "We Are the Champions"))
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
It("matches dateLoved after a past date", func() {
|
|
|
|
|
results := evaluateRule(`{"all":[{"after":{"dateloved":"2020-01-01"}}]}`)
|
|
|
|
|
Expect(results).To(ConsistOf("Come Together", "So What"))
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
It("matches dateRated after a past date", func() {
|
|
|
|
|
results := evaluateRule(`{"all":[{"after":{"daterated":"2020-01-01"}}]}`)
|
|
|
|
|
Expect(results).To(ConsistOf("Stairway To Heaven", "Bohemian Rhapsody"))
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
It("matches dateAdded inTheLast 1 day", func() {
|
|
|
|
|
results := evaluateRule(`{"all":[{"inTheLast":{"dateadded":1}}]}`)
|
|
|
|
|
Expect(results).To(ConsistOf("Come Together", "Something", "Stairway To Heaven", "Black Dog",
|
|
|
|
|
"So What", "Bohemian Rhapsody", "All Along the Watchtower", "We Are the Champions"))
|
|
|
|
|
})
|
refactor: centralize criteria sort parsing and extract smart playlist logic (#5415)
* test: add tests for recordingdate alias resolution in smart playlists
Signed-off-by: Deluan <deluan@navidrome.org>
* refactor: update FieldInfo structure and simplify fieldMap initialization
Signed-off-by: Deluan <deluan@navidrome.org>
* refactor: move sort parsing logic from persistence to criteria package
Extracted sort field parsing, validation, and direction handling from
persistence/criteria_sql.go into model/criteria/sort.go. The new
OrderByFields method on Criteria parses the Sort/Order strings into
validated SortField structs (field name + direction), resolving aliases
and handling +/- prefixes and order inversion. The persistence layer now
consumes these parsed fields and only handles SQL expression mapping.
This centralizes sort parsing to enforce consistent implementations.
* refactor: standardize field access in smartPlaylistCriteria structure
Signed-off-by: Deluan <deluan@navidrome.org>
* refactor: add ResolveLimit method to Criteria
Moved the percentage-limit resolution logic from playlist_repository
into Criteria.ResolveLimit, replacing the 3-line mutate-after-query
pattern with a single method call. The method preserves LimitPercent
rather than zeroing it, since IsPercentageLimit already returns false
once Limit is set, making the clear redundant and lossy.
* refactor: improve child playlist loading and error handling in refresh logic
Signed-off-by: Deluan <deluan@navidrome.org>
* refactor: extract smart playlist logic to dedicated files
Moved refreshSmartPlaylist, addSmartPlaylistAnnotationJoins, and
addCriteria methods from playlist_repository.go to a new
smart_playlist_repository.go file. Extracted all smart playlist tests
to smart_playlist_repository_test.go. Added DeferCleanup to the
"valid rules" test to fix ordering flakiness when Ginkgo randomizes
test execution across files.
* refactor: break refreshSmartPlaylist into smaller focused methods
Split the monolithic refreshSmartPlaylist method into discrete helpers
for readability: shouldRefreshSmartPlaylist for guard checks,
refreshChildPlaylists for recursive dependency refresh,
resolvePercentageLimit for count-based limit resolution,
buildSmartPlaylistQuery for assembling the SELECT with joins, and
addMediaFileAnnotationJoin to DRY up the repeated annotation join clause.
* refactor: deduplicate child playlist IDs in Criteria
Signed-off-by: Deluan <deluan@navidrome.org>
* refactor: simplify withSmartPlaylistOwner to accept model.User
Replaced separate ownerID string and ownerIsAdmin bool parameters with a
single model.User struct, reducing the field count in smartPlaylistCriteria
and making the option function signature clearer. Updated all call sites
and tests accordingly.
* fix: handle empty sort fields and propagate child playlist load errors
OrderByFields now falls back to [{title, asc}] when all user-supplied
sort fields are invalid, preventing empty ORDER BY clauses that would
produce invalid SQL in row_number() window functions. Also restored the
original behavior where a DB error loading child playlists aborts the
parent smart playlist refresh, by making refreshChildPlaylists return a
bool.
* refactor: log warning when no valid sort fields are found
Signed-off-by: Deluan <deluan@navidrome.org>
---------
Signed-off-by: Deluan <deluan@navidrome.org>
2026-04-26 10:49:59 -08:00
|
|
|
|
|
|
|
|
It("resolves recordingdate alias to the date column", func() {
|
|
|
|
|
results := evaluateRule(`{"all":[{"is":{"recordingdate":"1959"}}]}`)
|
|
|
|
|
Expect(results).To(ConsistOf("So What"))
|
|
|
|
|
})
|
2026-04-24 19:03:10 -08:00
|
|
|
})
|
|
|
|
|
|
|
|
|
|
Describe("Logic operators", func() {
|
|
|
|
|
It("matches with ALL (AND)", func() {
|
|
|
|
|
results := evaluateRule(`{"all":[{"is":{"genre":"Blues"}},{"gt":{"bpm":130}}]}`)
|
|
|
|
|
Expect(results).To(ConsistOf("Black Dog"))
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
It("matches with ANY (OR)", func() {
|
|
|
|
|
results := evaluateRule(`{"any":[{"is":{"genre":"Jazz"}},{"is":{"compilation":true}}]}`)
|
|
|
|
|
Expect(results).To(ConsistOf("So What", "We Are the Champions"))
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
It("matches nested all/any", func() {
|
|
|
|
|
results := evaluateRule(`{"all":[{"any":[{"is":{"genre":"Blues"}},{"is":{"genre":"Jazz"}}]},{"gt":{"year":1960}}]}`)
|
|
|
|
|
Expect(results).To(ConsistOf("Come Together", "Black Dog", "All Along the Watchtower"))
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
Describe("Sorting and limits", func() {
|
|
|
|
|
It("returns tracks sorted by year descending with limit", func() {
|
|
|
|
|
results := evaluateRuleOrdered(`{"all":[{"gt":{"year":0}}],"sort":"year","order":"desc","limit":2}`)
|
|
|
|
|
Expect(results).To(Equal([]string{"We Are the Champions", "Bohemian Rhapsody"}))
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
It("returns tracks sorted by title ascending", func() {
|
|
|
|
|
results := evaluateRuleOrdered(`{"all":[{"is":{"genre":"Blues"}}],"sort":"title","order":"asc"}`)
|
|
|
|
|
Expect(results).To(Equal([]string{"All Along the Watchtower", "Black Dog", "Come Together"}))
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
Describe("Combined real-world patterns", func() {
|
|
|
|
|
It("matches genre filter with exclusion and year range", func() {
|
|
|
|
|
results := evaluateRuleOrdered(`{
|
|
|
|
|
"all":[
|
|
|
|
|
{"any":[
|
|
|
|
|
{"is":{"genre":"Blues"}},
|
|
|
|
|
{"is":{"genre":"Folk"}}
|
|
|
|
|
]},
|
|
|
|
|
{"isNot":{"genre":"Jazz"}},
|
|
|
|
|
{"gt":{"year":1965}}
|
|
|
|
|
],
|
|
|
|
|
"sort":"-year,title"
|
|
|
|
|
}`)
|
|
|
|
|
Expect(results).To(Equal([]string{"Black Dog", "Stairway To Heaven", "Come Together", "All Along the Watchtower"}))
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
Describe("Playlist operators", func() {
|
|
|
|
|
It("matches tracks in a public regular playlist", func() {
|
2026-04-25 10:59:06 -08:00
|
|
|
refID := createPublicPlaylist(adminUser, "Come Together", "So What")
|
|
|
|
|
results := evaluateRuleAs(regularUser, `{"all":[{"inPlaylist":{"id":"`+refID+`"}}]}`)
|
2026-04-24 19:03:10 -08:00
|
|
|
Expect(results).To(ConsistOf("Come Together", "So What"))
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
It("matches tracks not in a public regular playlist", func() {
|
2026-04-25 10:59:06 -08:00
|
|
|
refID := createPublicPlaylist(adminUser, "Come Together", "So What")
|
|
|
|
|
results := evaluateRuleAs(regularUser, `{"all":[{"notInPlaylist":{"id":"`+refID+`"}}]}`)
|
2026-04-24 19:03:10 -08:00
|
|
|
Expect(results).To(ConsistOf("Something", "Stairway To Heaven", "Black Dog",
|
|
|
|
|
"Bohemian Rhapsody", "All Along the Watchtower", "We Are the Champions"))
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
It("recursively refreshes a referenced smart playlist owned by the same user", func() {
|
2026-04-25 10:59:06 -08:00
|
|
|
smartBID := createPublicSmartPlaylist(adminUser, `{"all":[{"is":{"genre":"Jazz"}}]}`)
|
|
|
|
|
results := evaluateRuleAs(adminUser, `{"all":[{"inPlaylist":{"id":"`+smartBID+`"}}]}`)
|
2026-04-24 19:03:10 -08:00
|
|
|
Expect(results).To(ConsistOf("So What"))
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
It("does not refresh a referenced smart playlist owned by another user", func() {
|
2026-04-25 10:59:06 -08:00
|
|
|
smartBID := createPublicSmartPlaylist(regularUser, `{"all":[{"is":{"genre":"Jazz"}}]}`)
|
|
|
|
|
results := evaluateRuleAs(adminUser, `{"all":[{"inPlaylist":{"id":"`+smartBID+`"}}]}`)
|
2026-04-24 19:03:10 -08:00
|
|
|
Expect(results).To(BeEmpty())
|
|
|
|
|
})
|
|
|
|
|
|
2026-04-25 10:59:06 -08:00
|
|
|
It("does not refresh a playlist or its children when an admin views another user's smart playlist", func() {
|
|
|
|
|
smartBID := createPrivateSmartPlaylist(adminUser, `{"all":[{"is":{"genre":"Jazz"}}]}`)
|
|
|
|
|
smartAID := createPublicSmartPlaylist(regularUser, `{"all":[{"inPlaylist":{"id":"`+smartBID+`"}}]}`)
|
|
|
|
|
|
|
|
|
|
loadedA, err := ds.Playlist(ctx).GetWithTracks(smartAID, true, false)
|
|
|
|
|
Expect(err).ToNot(HaveOccurred())
|
|
|
|
|
Expect(loadedA.Tracks).To(BeEmpty())
|
|
|
|
|
Expect(loadedA.EvaluatedAt).To(BeNil())
|
|
|
|
|
|
|
|
|
|
loadedB, err := ds.Playlist(ctx).Get(smartBID)
|
|
|
|
|
Expect(err).ToNot(HaveOccurred())
|
|
|
|
|
Expect(loadedB.EvaluatedAt).To(BeNil())
|
2026-04-24 19:03:10 -08:00
|
|
|
})
|
|
|
|
|
|
2026-04-25 10:59:06 -08:00
|
|
|
It("matches tracks from a private playlist owned by the same user", func() {
|
|
|
|
|
refID := createPrivatePlaylist(regularUser, "Come Together", "So What")
|
|
|
|
|
results := evaluateRuleAs(regularUser, `{"all":[{"inPlaylist":{"id":"`+refID+`"}}]}`)
|
|
|
|
|
Expect(results).To(ConsistOf("Come Together", "So What"))
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
It("allows admin-owned smart playlists to reference private playlists owned by other users", func() {
|
|
|
|
|
refID := createPrivatePlaylist(regularUser, "Bohemian Rhapsody")
|
|
|
|
|
results := evaluateRuleAs(adminUser, `{"all":[{"inPlaylist":{"id":"`+refID+`"}}]}`)
|
2026-04-24 19:03:10 -08:00
|
|
|
Expect(results).To(ConsistOf("Bohemian Rhapsody"))
|
|
|
|
|
})
|
|
|
|
|
|
2026-04-25 10:59:06 -08:00
|
|
|
It("does not match tracks from a private playlist owned by another regular user", func() {
|
|
|
|
|
refID := createPrivatePlaylist(adminUser, "Come Together", "So What")
|
|
|
|
|
results := evaluateRuleAs(regularUser, `{"all":[{"inPlaylist":{"id":"`+refID+`"}}]}`)
|
2026-04-24 19:03:10 -08:00
|
|
|
Expect(results).To(BeEmpty())
|
|
|
|
|
})
|
2026-04-25 10:59:06 -08:00
|
|
|
|
|
|
|
|
It("warns when a referenced playlist is inaccessible to the smart playlist owner", func() {
|
|
|
|
|
hook, cleanup := tests.LogHook()
|
|
|
|
|
defer cleanup()
|
|
|
|
|
|
|
|
|
|
refID := createPrivatePlaylist(adminUser, "Come Together")
|
|
|
|
|
results := evaluateRuleAs(regularUser, `{"all":[{"notInPlaylist":{"id":"`+refID+`"}}]}`)
|
|
|
|
|
Expect(results).To(ConsistOf("Come Together", "Something", "Stairway To Heaven", "Black Dog",
|
|
|
|
|
"So What", "Bohemian Rhapsody", "All Along the Watchtower", "We Are the Champions"))
|
|
|
|
|
|
|
|
|
|
Expect(hook.LastEntry()).ToNot(BeNil())
|
|
|
|
|
Expect(hook.LastEntry().Level).To(Equal(logrus.WarnLevel))
|
|
|
|
|
Expect(hook.LastEntry().Message).To(Equal("Referenced playlist is not accessible to smart playlist owner"))
|
|
|
|
|
Expect(hook.LastEntry().Data).To(HaveKeyWithValue("childId", refID))
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
It("matches tracks in a public playlist owned by another user", func() {
|
|
|
|
|
refID := createPublicPlaylist(adminUser, "Bohemian Rhapsody")
|
|
|
|
|
results := evaluateRuleAs(regularUser, `{"all":[{"inPlaylist":{"id":"`+refID+`"}}]}`)
|
|
|
|
|
Expect(results).To(ConsistOf("Bohemian Rhapsody"))
|
|
|
|
|
})
|
|
|
|
|
|
2026-04-24 19:03:10 -08:00
|
|
|
})
|
2026-04-28 15:40:08 -08:00
|
|
|
|
|
|
|
|
Describe("isMissing/isPresent operators", func() {
|
|
|
|
|
It("isMissing finds tracks without grouping tag", func() {
|
|
|
|
|
results := evaluateRule(`{"all":[{"isMissing":{"grouping":true}}]}`)
|
|
|
|
|
Expect(results).To(ConsistOf("Stairway To Heaven", "Black Dog", "So What",
|
|
|
|
|
"Bohemian Rhapsody", "All Along the Watchtower", "We Are the Champions"))
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
It("isMissing false finds tracks with grouping tag", func() {
|
|
|
|
|
results := evaluateRule(`{"all":[{"isMissing":{"grouping":false}}]}`)
|
|
|
|
|
Expect(results).To(ConsistOf("Come Together", "Something"))
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
It("isPresent finds tracks with grouping tag", func() {
|
|
|
|
|
results := evaluateRule(`{"all":[{"isPresent":{"grouping":true}}]}`)
|
|
|
|
|
Expect(results).To(ConsistOf("Come Together", "Something"))
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
It("isPresent false finds tracks without grouping tag", func() {
|
|
|
|
|
results := evaluateRule(`{"all":[{"isPresent":{"grouping":false}}]}`)
|
|
|
|
|
Expect(results).To(ConsistOf("Stairway To Heaven", "Black Dog", "So What",
|
|
|
|
|
"Bohemian Rhapsody", "All Along the Watchtower", "We Are the Champions"))
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
It("isMissing returns all tracks for a tag nobody has", func() {
|
|
|
|
|
results := evaluateRule(`{"all":[{"isMissing":{"lyricist":true}}]}`)
|
|
|
|
|
Expect(results).To(ConsistOf("Come Together", "Something", "Stairway To Heaven", "Black Dog",
|
|
|
|
|
"So What", "Bohemian Rhapsody", "All Along the Watchtower", "We Are the Champions"))
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
It("isPresent returns all tracks for a role everyone has", func() {
|
|
|
|
|
results := evaluateRule(`{"all":[{"isPresent":{"composer":true}}]}`)
|
|
|
|
|
Expect(results).To(ConsistOf("Come Together", "Something", "Stairway To Heaven", "Black Dog",
|
|
|
|
|
"So What", "Bohemian Rhapsody", "All Along the Watchtower", "We Are the Champions"))
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
It("combines isMissing with other operators", func() {
|
|
|
|
|
results := evaluateRule(`{"all":[{"isMissing":{"grouping":true}},{"is":{"genre":"Blues"}}]}`)
|
|
|
|
|
Expect(results).To(ConsistOf("Black Dog", "All Along the Watchtower"))
|
|
|
|
|
})
|
|
|
|
|
})
|
fix(smartplaylist): support isMissing/isPresent operators on ReplayGain fields (#5585)
* fix(smartplaylist): support isMissing/isPresent operators on ReplayGain fields
ReplayGain values are stored in dedicated nullable columns (rg_album_gain,
rg_album_peak, rg_track_gain, rg_track_peak) rather than in the media_file.tags
JSON blob. The isMissing/isPresent operators previously only supported tag and
role fields, causing two failure modes:
1. Using the documented alias names (replaygain_album_gain etc.) from PR #5256:
these got registered as JSON tags from mappings.yaml, so isMissing queried
json_tree(media_file.tags, '$.replaygain_album_gain') which is always empty
-> the playlist matched ALL songs.
2. Using the canonical field names (rgalbumgain etc.): not a tag/role, so SQL
generation returned an error. Because refreshSmartPlaylist deletes old tracks
before regenerating, the abort left the playlist empty.
Fix: add Nullable bool to FieldInfo and mark the four ReplayGain fields. Add
static alias entries (replaygain_album_gain -> rgalbumgain etc.) with
Numeric+Nullable set; because AddTagNames skips names already in the field map,
these static entries take precedence over the mappings.yaml tag registration.
missingExpr now emits IS NULL / IS NOT NULL for nullable column fields instead
of the json_tree lookup.
Fixes #5584
* chore(smartplaylist): address code review feedback
- Simplify the isMissing/isPresent unsupported-field error message,
removing the internal "nullable fields" jargon
- Standardize comments on mappings.yaml (the actual filename)
- Clarify the alias precedence comment in the LookupField test
2026-06-10 17:12:31 -08:00
|
|
|
|
|
|
|
|
// ReplayGain values are stored in nullable media_file columns (not in the tags JSON), so
|
|
|
|
|
// isMissing/isPresent translate to IS [NOT] NULL checks on those columns (issue #5584).
|
|
|
|
|
Describe("isMissing/isPresent on ReplayGain fields", func() {
|
|
|
|
|
It("isMissing finds tracks without album gain", func() {
|
|
|
|
|
results := evaluateRule(`{"all":[{"isMissing":{"rgalbumgain":true}}]}`)
|
|
|
|
|
Expect(results).To(ConsistOf("Stairway To Heaven", "Black Dog", "So What",
|
|
|
|
|
"Bohemian Rhapsody", "All Along the Watchtower", "We Are the Champions"))
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
It("isMissing false finds tracks with album gain", func() {
|
|
|
|
|
results := evaluateRule(`{"all":[{"isMissing":{"rgalbumgain":false}}]}`)
|
|
|
|
|
Expect(results).To(ConsistOf("Come Together", "Something"))
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
It("isPresent finds tracks with album gain", func() {
|
|
|
|
|
results := evaluateRule(`{"all":[{"isPresent":{"rgalbumgain":true}}]}`)
|
|
|
|
|
Expect(results).To(ConsistOf("Come Together", "Something"))
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
It("isPresent finds tracks with album peak", func() {
|
|
|
|
|
results := evaluateRule(`{"all":[{"isPresent":{"rgalbumpeak":true}}]}`)
|
|
|
|
|
Expect(results).To(ConsistOf("Come Together", "Something"))
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
It("isMissing distinguishes track gain from album gain", func() {
|
|
|
|
|
results := evaluateRule(`{"all":[{"isMissing":{"rgtrackgain":true}}]}`)
|
|
|
|
|
Expect(results).To(ConsistOf("Black Dog", "So What", "Bohemian Rhapsody",
|
|
|
|
|
"All Along the Watchtower", "We Are the Champions"))
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
It("isPresent finds tracks with track gain", func() {
|
|
|
|
|
results := evaluateRule(`{"all":[{"isPresent":{"rgtrackgain":true}}]}`)
|
|
|
|
|
Expect(results).To(ConsistOf("Come Together", "Something", "Stairway To Heaven"))
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
It("resolves the replaygain_album_gain alias to the rgalbumgain column", func() {
|
|
|
|
|
results := evaluateRule(`{"all":[{"isMissing":{"replaygain_album_gain":true}}]}`)
|
|
|
|
|
Expect(results).To(ConsistOf("Stairway To Heaven", "Black Dog", "So What",
|
|
|
|
|
"Bohemian Rhapsody", "All Along the Watchtower", "We Are the Champions"))
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
It("resolves the replaygain_track_gain alias to the rgtrackgain column", func() {
|
|
|
|
|
results := evaluateRule(`{"all":[{"isPresent":{"replaygain_track_gain":true}}]}`)
|
|
|
|
|
Expect(results).To(ConsistOf("Come Together", "Something", "Stairway To Heaven"))
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
It("supports numeric comparisons through the replaygain_* alias", func() {
|
|
|
|
|
results := evaluateRule(`{"all":[{"gt":{"replaygain_track_gain":-7.5}}]}`)
|
|
|
|
|
Expect(results).To(ConsistOf("Come Together", "Something"))
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
It("combines isMissing on ReplayGain with other operators", func() {
|
|
|
|
|
results := evaluateRule(`{"all":[{"isMissing":{"rgalbumgain":true}},{"is":{"genre":"Blues"}}]}`)
|
|
|
|
|
Expect(results).To(ConsistOf("Black Dog", "All Along the Watchtower"))
|
|
|
|
|
})
|
|
|
|
|
})
|
2026-04-24 19:03:10 -08:00
|
|
|
})
|