🏡 index : ironmagma/quintodrome.git

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"))
		})
	})

	// 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"))
		})
	})
})