quintodrome/persistence/e2e/smartplaylist_test.go
Deluan Quintão 46b4dcd5f6
feat(smartplaylist): add isMissing and isPresent operators (#5436)
* feat(smartplaylist): add IsMissing and IsPresent operator types

Add two new Expression types for detecting absent/present tags and
roles in smart playlist criteria. Includes JSON marshal/unmarshal
support and Walk visitor registration.

* test(smartplaylist): add JSON marshal/unmarshal tests for isMissing/isPresent

* feat(smartplaylist): add SQL generation for isMissing/isPresent operators

Tags check json_tree(media_file.tags) for key existence.
Roles check json_tree(media_file.participants) for key existence.
Regular DB column fields are rejected with an error.

* test(smartplaylist): add e2e tests for isMissing/isPresent operators

Tests cover tag presence/absence with selective matching (grouping),
universal absence (lyricist role), universal presence (composer role),
and combined operator usage.

* refactor(smartplaylist): use strconv.ParseBool in IsTruthy

Replace hand-rolled string truthiness check with strconv.ParseBool,
which correctly handles standard boolean strings and rejects
unrecognized values as false.

* refactor(smartplaylist): clarify missingExpr parameter naming

Rename defaultNegate to checkAbsence and extract truthy local for
readability. The XNOR logic (checkAbsence == truthy) is now easier
to follow: isMissing passes true, isPresent passes false.

* refactor(smartplaylist): reuse jsonExpr in missingExpr, improve errors

- tagCond/roleCond now handle nil cond (existence-only check)
- missingExpr delegates to jsonExpr(info, nil, negate) instead of
  building SQL manually
- Better error messages: unknown fields now report the field name
2026-04-28 19:40:08 -04:00

374 lines
15 KiB
Go

package e2e
import (
"github.com/navidrome/navidrome/tests"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
"github.com/sirupsen/logrus"
)
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"))
})
It("resolves recordingdate alias to the date column", func() {
results := evaluateRule(`{"all":[{"is":{"recordingdate":"1959"}}]}`)
Expect(results).To(ConsistOf("So What"))
})
})
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() {
refID := createPublicPlaylist(adminUser, "Come Together", "So What")
results := evaluateRuleAs(regularUser, `{"all":[{"inPlaylist":{"id":"`+refID+`"}}]}`)
Expect(results).To(ConsistOf("Come Together", "So What"))
})
It("matches tracks not in a public regular playlist", func() {
refID := createPublicPlaylist(adminUser, "Come Together", "So What")
results := evaluateRuleAs(regularUser, `{"all":[{"notInPlaylist":{"id":"`+refID+`"}}]}`)
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() {
smartBID := createPublicSmartPlaylist(adminUser, `{"all":[{"is":{"genre":"Jazz"}}]}`)
results := evaluateRuleAs(adminUser, `{"all":[{"inPlaylist":{"id":"`+smartBID+`"}}]}`)
Expect(results).To(ConsistOf("So What"))
})
It("does not refresh a referenced smart playlist owned by another user", func() {
smartBID := createPublicSmartPlaylist(regularUser, `{"all":[{"is":{"genre":"Jazz"}}]}`)
results := evaluateRuleAs(adminUser, `{"all":[{"inPlaylist":{"id":"`+smartBID+`"}}]}`)
Expect(results).To(BeEmpty())
})
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())
})
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+`"}}]}`)
Expect(results).To(ConsistOf("Bohemian Rhapsody"))
})
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+`"}}]}`)
Expect(results).To(BeEmpty())
})
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"))
})
})
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"))
})
})
})