navidrome/persistence/e2e/smartplaylist_test.go
Deluan Quintão dce48ff650
feat(smartplaylist): add album-level fields for sorting and filtering (#5899)
Smart playlists could only sort by the track-level `dateadded`, which scatters
an album's tracks because every track has its own timestamp. There was no way
to express "newest albums first, tracks in album order".

Adds five fields backed by the album table: albumdateadded, albumdatemodified,
albumduration, albumsongcount and albumsize, so `"sort": "-albumdateadded,
tracknumber"` now works. They filter as well as sort, which also enables rules
like "everything from albums added this month" or "skip singles and EPs".

These need the album table rather than the album_annotation table the existing
album* fields join, so they get their own bit in the join mask. The bitmask
already unions joins from both the expression and the sort fields, so a
sort-only reference pulls in the join for the main query while correctly
staying out of the percentage-limit count query.

No COALESCE default is used. That mechanism exists because an album_annotation
row is genuinely often absent; the album row always exists and song_count,
duration and size are NOT NULL, so leaving the columns bare keeps filters
index-friendly.

albumdatemodified follows the existing track-level `datemodified` naming rather
than the `albumdateupdated` spelling used in the request.

albumreleasedate was considered and rejected: album.release_date is
allOrNothing() over the tracks, so it equals the track-level releasedate when
they agree and collapses to empty when they disagree.

Closes https://github.com/navidrome/navidrome/discussions/5347
2026-08-06 10:59:50 -04:00

469 lines
19 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("Album aggregate fields", func() {
// Abbey Road and IV have two tracks each; the other four albums have one.
It("matches albums with more than one track", func() {
results := evaluateRule(`{"all":[{"gt":{"albumsongcount":1}}]}`)
Expect(results).To(ConsistOf("Come Together", "Something", "Stairway To Heaven", "Black Dog"))
})
It("matches single-track albums", func() {
results := evaluateRule(`{"all":[{"is":{"albumsongcount":1}}]}`)
Expect(results).To(ConsistOf("So What", "Bohemian Rhapsody", "All Along the Watchtower",
"We Are the Champions"))
})
It("matches albumDateAdded inTheLast 1 day", func() {
results := evaluateRule(`{"all":[{"inTheLast":{"albumdateadded":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("matches albumDateModified before a far-future date", func() {
results := evaluateRule(`{"all":[{"before":{"albumdatemodified":"2099-01-01"}}]}`)
Expect(results).To(HaveLen(8))
})
// Fixture durations are randomized, so only the aggregate being populated can be asserted.
It("resolves albumDuration and albumSize to non-zero aggregates", func() {
Expect(evaluateRule(`{"all":[{"gt":{"albumduration":0}}]}`)).To(HaveLen(8))
Expect(evaluateRule(`{"all":[{"gt":{"albumsize":0}}]}`)).To(HaveLen(8))
})
It("groups by album date and orders within the album (issue #5347)", func() {
results := evaluateRuleOrdered(
`{"all":[{"is":{"album":"Abbey Road"}}],"sort":"-albumdateadded,tracknumber"}`)
Expect(results).To(Equal([]string{"Come Together", "Something"}))
})
})
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"))
})
})
// 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"))
})
})
})