mirror of
https://github.com/navidrome/navidrome.git
synced 2026-10-08 02:17:25 +02:00
* feat: Add support for referencing playlists using paths Signed-off-by: David <dvedvick@gmail.com> * feat: Support relative playlist paths in smartlists Signed-off-by: David <dvedvick@gmail.com> * fix(smartplaylists): protect against nil panic Signed-off-by: David <dvedvick@gmail.com> * fix(smartplaylists): refreshing child playlists Signed-off-by: David <dvedvick@gmail.com> * chore(smartplaylists): log field parsing error Signed-off-by: David <dvedvick@gmail.com> * fix(smartplaylists): handle empty playlist paths Signed-off-by: David <dvedvick@gmail.com> * refactor(smartplaylists): make NormalizeChildPaths non-mutating Signed-off-by: David <dvedvick@gmail.com> * fix(smartplaylists): stop warning on every inPlaylist rule without the looked-up field Rules that reference a playlist by id have no path field, and the reverse, so the warning fired on every refresh. The log call also had a bad argument count. * fix(smartplaylists): ignore empty inPlaylist id and path references An empty path matched every playlist without a file path, including the referencing playlist itself, so the refresh recursed until the stack overflowed. An empty id also shadowed a valid path in the same rule. * fix(smartplaylists): match inPlaylist paths in both NFC and NFD forms A playlist path is stored in the Unicode form the filesystem reports, which can differ from the form typed in the .nsp file. The exact comparison then found no playlist for names with accents. * fix(smartplaylists): keep all criteria fields when normalizing child paths The field-by-field copy dropped RefreshDelay. * fix(smartplaylists): clean absolute inPlaylist path references Only relative references were cleaned, so an absolute reference such as /music/./child.nsp never matched the stored /music/child.nsp. * fix(smartplaylists): stop infinite recursion on playlists that reference each other Two smart playlists referencing each other, by id or by path, recursed until the stack overflowed and the server died. The refresh now tracks visited playlists. * fix(smartplaylists): resolve inPlaylist path references with OS-native separators Playlist.Path is OS-native, but references in a .nsp file use forward slashes. On Windows they never matched, and a leading slash was not seen as absolute. The specs now build OS-native paths, so they also run on Windows. * fix(smartplaylists): warn when a relative inPlaylist path cannot be resolved A playlist created in the UI has no file path, so a relative reference silently matched nothing. * refactor(smartplaylists): simplify child playlist reference handling Share one extractor for child ids and paths, return only the normalized rules instead of a playlist copy, and resolve each path reference in a single switch. * test(smartplaylists): store the Unicode child path in OS-native form Playlist.Path is OS-native, so on Windows the forward-slash fixture never matched the normalized reference. --------- Signed-off-by: David <dvedvick@gmail.com> Co-authored-by: Deluan Quintão <deluan@navidrome.org>
647 lines
36 KiB
Go
647 lines
36 KiB
Go
package persistence
|
|
|
|
import (
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/navidrome/navidrome/model"
|
|
"github.com/navidrome/navidrome/model/criteria"
|
|
. "github.com/onsi/ginkgo/v2"
|
|
. "github.com/onsi/gomega"
|
|
)
|
|
|
|
var _ = Describe("Smart playlist criteria SQL", func() {
|
|
BeforeEach(func() {
|
|
criteria.AddRoles([]string{"artist", "composer", "producer"})
|
|
criteria.AddTagNames([]string{"genre", "mood", "releasetype", "recordingdate", "replaygain_album_gain"})
|
|
criteria.AddNumericTags([]string{"rate"})
|
|
})
|
|
|
|
DescribeTable("expressions",
|
|
func(expr criteria.Expression, expectedSQL string, expectedArgs ...any) {
|
|
sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: expr}).where()
|
|
Expect(err).ToNot(HaveOccurred())
|
|
|
|
sql, args, err := sqlizer.ToSql()
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(sql).To(Equal(expectedSQL))
|
|
Expect(args).To(HaveExactElements(expectedArgs...))
|
|
},
|
|
Entry("all group",
|
|
criteria.All{criteria.Contains{"title": "love"}, criteria.Gt{"rating": 3}},
|
|
"(media_file.title LIKE ? AND annotation.rating > ?)", "%love%", 3),
|
|
Entry("any group",
|
|
criteria.Any{criteria.Is{"title": "Low Rider"}, criteria.Is{"album": "Best Of"}},
|
|
"(media_file.title = ? OR media_file.album = ?)", "Low Rider", "Best Of"),
|
|
Entry("is string", criteria.Is{"title": "Low Rider"}, "media_file.title = ?", "Low Rider"),
|
|
Entry("is bool", criteria.Is{"loved": true}, "annotation.starred = ?", true),
|
|
Entry("is numeric list", criteria.Is{"library_id": []int{1, 2}}, "media_file.library_id IN (?,?)", 1, 2),
|
|
Entry("is not", criteria.IsNot{"title": "Low Rider"}, "media_file.title <> ?", "Low Rider"),
|
|
Entry("gt", criteria.Gt{"playCount": 10}, "annotation.play_count > ?", 10),
|
|
Entry("lt", criteria.Lt{"playCount": 10}, "(annotation.play_count < ? OR annotation.play_count IS NULL)", 10),
|
|
Entry("contains", criteria.Contains{"title": "Low Rider"}, "media_file.title LIKE ?", "%Low Rider%"),
|
|
Entry("not contains", criteria.NotContains{"title": "Low Rider"}, "media_file.title NOT LIKE ?", "%Low Rider%"),
|
|
Entry("starts with", criteria.StartsWith{"title": "Low Rider"}, "media_file.title LIKE ?", "Low Rider%"),
|
|
Entry("ends with", criteria.EndsWith{"title": "Low Rider"}, "media_file.title LIKE ?", "%Low Rider"),
|
|
Entry("in range", criteria.InTheRange{"year": []int{1980, 1990}}, "(media_file.year >= ? AND media_file.year <= ?)", 1980, 1990),
|
|
Entry("before", criteria.Before{"lastPlayed": time.Date(2021, 10, 1, 0, 0, 0, 0, time.Local)}, "annotation.play_date < ?", time.Date(2021, 10, 1, 0, 0, 0, 0, time.Local)),
|
|
Entry("after", criteria.After{"lastPlayed": time.Date(2021, 10, 1, 0, 0, 0, 0, time.Local)}, "annotation.play_date > ?", time.Date(2021, 10, 1, 0, 0, 0, 0, time.Local)),
|
|
Entry("in playlist [path]", criteria.InPlaylist{"path": "lacuslacus.nsp"}, "media_file.id IN (SELECT media_file_id FROM playlist_tracks pl LEFT JOIN playlist on pl.playlist_id = playlist.id WHERE (playlist.path IN (?) AND playlist.public = ?))", "lacuslacus.nsp", 1),
|
|
Entry("in playlist [id]", criteria.InPlaylist{"id": "deadbeef-dead-beef"}, "media_file.id IN (SELECT media_file_id FROM playlist_tracks pl LEFT JOIN playlist on pl.playlist_id = playlist.id WHERE (pl.playlist_id = ? AND playlist.public = ?))", "deadbeef-dead-beef", 1),
|
|
Entry("in playlist [empty id falls back to path]", criteria.InPlaylist{"id": "", "path": "/music/x.nsp"}, "media_file.id IN (SELECT media_file_id FROM playlist_tracks pl LEFT JOIN playlist on pl.playlist_id = playlist.id WHERE (playlist.path IN (?) AND playlist.public = ?))", "/music/x.nsp", 1),
|
|
Entry("in playlist [decomposed unicode path]", criteria.InPlaylist{"path": "/m\u00fasica/x.nsp"}, "media_file.id IN (SELECT media_file_id FROM playlist_tracks pl LEFT JOIN playlist on pl.playlist_id = playlist.id WHERE (playlist.path IN (?,?) AND playlist.public = ?))", "/m\u00fasica/x.nsp", "/mu\u0301sica/x.nsp", 1),
|
|
Entry("not in playlist", criteria.NotInPlaylist{"id": "deadbeef-dead-beef"}, "media_file.id NOT IN (SELECT media_file_id FROM playlist_tracks pl LEFT JOIN playlist on pl.playlist_id = playlist.id WHERE (pl.playlist_id = ? AND playlist.public = ?))", "deadbeef-dead-beef", 1),
|
|
Entry("album annotation", criteria.Gt{"albumRating": 3}, "album_annotation.rating > ?", 3),
|
|
Entry("artist annotation", criteria.Is{"artistLoved": true}, "artist_annotation.starred = ?", true),
|
|
Entry("album column", criteria.Gt{"albumSongCount": 5}, "album.song_count > ?", 5),
|
|
Entry("album duration column", criteria.Lt{"albumDuration": 600}, "album.duration < ?", 600),
|
|
Entry("album size column", criteria.Gt{"albumSize": 1000}, "album.size > ?", 1000),
|
|
Entry("album date column", criteria.After{"albumDateAdded": time.Date(2021, 10, 1, 0, 0, 0, 0, time.Local)}, "album.created_at > ?", time.Date(2021, 10, 1, 0, 0, 0, 0, time.Local)),
|
|
Entry("album modified column", criteria.Before{"albumDateModified": time.Date(2021, 10, 1, 0, 0, 0, 0, time.Local)}, "album.updated_at < ?", time.Date(2021, 10, 1, 0, 0, 0, 0, time.Local)),
|
|
// Annotation fields use a COALESCE default (0 for numeric, false for bool) so that tracks
|
|
// with no annotation row behave as that default. To keep the annotation index usable, the
|
|
// COALESCE is dropped when the compared value cannot match the default (the missing-row
|
|
// case is then naturally excluded); otherwise an explicit `OR col IS NULL` preserves it.
|
|
Entry("is safe (value != default)", criteria.Is{"playCount": 3}, "annotation.play_count = ?", 3),
|
|
Entry("is unsafe (value == default)", criteria.Is{"playCount": 0},
|
|
"(annotation.play_count = ? OR annotation.play_count IS NULL)", 0),
|
|
Entry("is bool false (value == default)", criteria.Is{"loved": false},
|
|
"(annotation.starred = ? OR annotation.starred IS NULL)", false),
|
|
Entry("gt safe (value >= default)", criteria.Gt{"playCount": 0}, "annotation.play_count > ?", 0),
|
|
Entry("gt unsafe (value < default)", criteria.Gt{"playCount": -1},
|
|
"(annotation.play_count > ? OR annotation.play_count IS NULL)", -1),
|
|
Entry("lt safe (value <= default)", criteria.Lt{"playCount": 0}, "annotation.play_count < ?", 0),
|
|
Entry("lt unsafe (value > default)", criteria.Lt{"playCount": 5},
|
|
"(annotation.play_count < ? OR annotation.play_count IS NULL)", 5),
|
|
Entry("isNot annotation keeps null match", criteria.IsNot{"playCount": 3},
|
|
"(annotation.play_count <> ? OR annotation.play_count IS NULL)", 3),
|
|
Entry("isNot annotation value == default", criteria.IsNot{"playCount": 0},
|
|
"annotation.play_count <> ?", 0),
|
|
Entry("in range spanning default", criteria.InTheRange{"playCount": []int{-1, 5}},
|
|
"((annotation.play_count >= ? OR annotation.play_count IS NULL) AND (annotation.play_count <= ? OR annotation.play_count IS NULL))", -1, 5),
|
|
Entry("in range above default", criteria.InTheRange{"playCount": []int{1, 5}},
|
|
"(annotation.play_count >= ? AND (annotation.play_count <= ? OR annotation.play_count IS NULL))", 1, 5),
|
|
// A list value can't drive the index and a default-inclusive list has per-element NULL
|
|
// semantics, so the COALESCE form is kept to stay equivalent to the original.
|
|
Entry("is list keeps coalesce", criteria.Is{"playCount": []int{0, 3}},
|
|
"COALESCE(annotation.play_count, 0) IN (?,?)", 0, 3),
|
|
// LIKE operators can't use the column index, so annotation fields keep the COALESCE form to
|
|
// match missing-annotation rows exactly as before (a NULL column never matches LIKE).
|
|
Entry("contains annotation keeps coalesce", criteria.Contains{"playCount": 0},
|
|
"COALESCE(annotation.play_count, 0) LIKE ?", "%0%"),
|
|
Entry("starts with annotation keeps coalesce", criteria.StartsWith{"rating": 5},
|
|
"COALESCE(annotation.rating, 0) LIKE ?", "5%"),
|
|
Entry("not contains annotation keeps coalesce", criteria.NotContains{"playCount": 0},
|
|
"COALESCE(annotation.play_count, 0) NOT LIKE ?", "%0%"),
|
|
// Bool annotation fields only have a clean index-friendly form for equality; ordering
|
|
// comparators keep the COALESCE form so the missing-row default is honored exactly.
|
|
Entry("gt bool keeps coalesce", criteria.Gt{"loved": false},
|
|
"COALESCE(annotation.starred, false) > ?", false),
|
|
// A list value on a bool field is non-scalar, so it keeps the COALESCE form too (same as the
|
|
// numeric list case) — otherwise a NULL column would diverge from the original.
|
|
Entry("is bool list keeps coalesce", criteria.Is{"loved": []any{true}},
|
|
"COALESCE(annotation.starred, false) IN (?)", true),
|
|
Entry("tag is", criteria.Is{"genre": "Rock"}, "exists (select 1 from json_tree(media_file.tags, '$.genre') where key='value' and value = ?)", "Rock"),
|
|
Entry("tag is not", criteria.IsNot{"genre": "Rock"}, "not exists (select 1 from json_tree(media_file.tags, '$.genre') where key='value' and value = ?)", "Rock"),
|
|
Entry("tag contains", criteria.Contains{"genre": "Rock"}, "exists (select 1 from json_tree(media_file.tags, '$.genre') where key='value' and value LIKE ?)", "%Rock%"),
|
|
Entry("tag not contains", criteria.NotContains{"genre": "Rock"}, "not exists (select 1 from json_tree(media_file.tags, '$.genre') where key='value' and value LIKE ?)", "%Rock%"),
|
|
Entry("numeric tag", criteria.Lt{"rate": 6}, "exists (select 1 from json_tree(media_file.tags, '$.rate') where key='value' and CAST(value AS REAL) < ?)", 6),
|
|
Entry("tag alias", criteria.Is{"albumtype": "album"}, "exists (select 1 from json_tree(media_file.tags, '$.releasetype') where key='value' and value = ?)", "album"),
|
|
Entry("field alias via tag registration", criteria.Is{"recordingdate": "2024-01-01"}, "media_file.date = ?", "2024-01-01"),
|
|
Entry("role is", criteria.Is{"artist": "u2"}, "exists (select 1 from media_file_artists mfa join artist on artist.id = mfa.artist_id where mfa.media_file_id = media_file.id and mfa.role = ? and artist.name = ?)", "artist", "u2"),
|
|
Entry("role contains", criteria.Contains{"composer": "Lennon"}, "exists (select 1 from media_file_artists mfa join artist on artist.id = mfa.artist_id where mfa.media_file_id = media_file.id and mfa.role = ? and artist.name LIKE ?)", "composer", "%Lennon%"),
|
|
Entry("role not contains", criteria.NotContains{"artist": "u2"}, "not exists (select 1 from media_file_artists mfa join artist on artist.id = mfa.artist_id where mfa.media_file_id = media_file.id and mfa.role = ? and artist.name LIKE ?)", "artist", "%u2%"),
|
|
// ReplayGain fields
|
|
Entry("rgAlbumGain is", criteria.Is{"rgAlbumGain": 0}, "media_file.rg_album_gain = ?", 0),
|
|
Entry("rgAlbumGain gt", criteria.Gt{"rgAlbumGain": -6.0}, "media_file.rg_album_gain > ?", -6.0),
|
|
Entry("rgTrackPeak lt", criteria.Lt{"rgTrackPeak": 1.0}, "media_file.rg_track_peak < ?", 1.0),
|
|
// isMissing — tags
|
|
Entry("isMissing tag [true]", criteria.IsMissing{"genre": true},
|
|
"not exists (select 1 from json_tree(media_file.tags, '$.genre') where key='value')"),
|
|
Entry("isMissing tag [false]", criteria.IsMissing{"genre": false},
|
|
"exists (select 1 from json_tree(media_file.tags, '$.genre') where key='value')"),
|
|
// isMissing — roles
|
|
Entry("isMissing role [true]", criteria.IsMissing{"artist": true},
|
|
"not exists (select 1 from media_file_artists mfa where mfa.media_file_id = media_file.id and mfa.role = ?)", "artist"),
|
|
Entry("isMissing role [false]", criteria.IsMissing{"artist": false},
|
|
"exists (select 1 from media_file_artists mfa where mfa.media_file_id = media_file.id and mfa.role = ?)", "artist"),
|
|
// isPresent — tags
|
|
Entry("isPresent tag [true]", criteria.IsPresent{"genre": true},
|
|
"exists (select 1 from json_tree(media_file.tags, '$.genre') where key='value')"),
|
|
Entry("isPresent tag [false]", criteria.IsPresent{"genre": false},
|
|
"not exists (select 1 from json_tree(media_file.tags, '$.genre') where key='value')"),
|
|
// isPresent — roles
|
|
Entry("isPresent role [true]", criteria.IsPresent{"composer": true},
|
|
"exists (select 1 from media_file_artists mfa where mfa.media_file_id = media_file.id and mfa.role = ?)", "composer"),
|
|
Entry("isPresent role [false]", criteria.IsPresent{"composer": false},
|
|
"not exists (select 1 from media_file_artists mfa where mfa.media_file_id = media_file.id and mfa.role = ?)", "composer"),
|
|
// isMissing/isPresent — nullable column fields (ReplayGain)
|
|
Entry("isMissing rgAlbumGain [true]", criteria.IsMissing{"rgAlbumGain": true},
|
|
"(media_file.rg_album_gain IS NULL)"),
|
|
Entry("isMissing rgAlbumGain [false]", criteria.IsMissing{"rgAlbumGain": false},
|
|
"(media_file.rg_album_gain IS NOT NULL)"),
|
|
Entry("isPresent rgTrackPeak [true]", criteria.IsPresent{"rgTrackPeak": true},
|
|
"(media_file.rg_track_peak IS NOT NULL)"),
|
|
Entry("isPresent rgTrackPeak [false]", criteria.IsPresent{"rgTrackPeak": false},
|
|
"(media_file.rg_track_peak IS NULL)"),
|
|
// isMissing — replaygain_* tag-name alias resolves to the nullable column (issue #5584)
|
|
Entry("isMissing replaygain_album_gain alias [true]", criteria.IsMissing{"replaygain_album_gain": true},
|
|
"(media_file.rg_album_gain IS NULL)"),
|
|
Entry("isPresent replaygain_album_gain alias [true]", criteria.IsPresent{"replaygain_album_gain": true},
|
|
"(media_file.rg_album_gain IS NOT NULL)"),
|
|
// isMissing/isPresent — string column fields (empty string means missing)
|
|
Entry("isMissing mbz_recording_id [true]", criteria.IsMissing{"mbz_recording_id": true},
|
|
"(media_file.mbz_recording_id IS NULL OR media_file.mbz_recording_id = ?)", ""),
|
|
Entry("isMissing mbz_recording_id [false]", criteria.IsMissing{"mbz_recording_id": false},
|
|
"(media_file.mbz_recording_id IS NOT NULL AND media_file.mbz_recording_id <> ?)", ""),
|
|
Entry("isPresent mbz_album_id [true]", criteria.IsPresent{"mbz_album_id": true},
|
|
"(media_file.mbz_album_id IS NOT NULL AND media_file.mbz_album_id <> ?)", ""),
|
|
Entry("isPresent mbz_album_id [false]", criteria.IsPresent{"mbz_album_id": false},
|
|
"(media_file.mbz_album_id IS NULL OR media_file.mbz_album_id = ?)", ""),
|
|
// lyrics: absence is encoded as '' or '[]' (empty serialized LyricList)
|
|
Entry("isMissing lyrics [true]", criteria.IsMissing{"lyrics": true},
|
|
"(media_file.lyrics IS NULL OR media_file.lyrics = ? OR media_file.lyrics = ?)", "", "[]"),
|
|
Entry("isPresent lyrics [true]", criteria.IsPresent{"lyrics": true},
|
|
"(media_file.lyrics IS NOT NULL AND media_file.lyrics <> ? AND media_file.lyrics <> ?)", "", "[]"),
|
|
Entry("isMissing lyrics [false]", criteria.IsMissing{"lyrics": false},
|
|
"(media_file.lyrics IS NOT NULL AND media_file.lyrics <> ? AND media_file.lyrics <> ?)", "", "[]"),
|
|
Entry("isPresent lyrics [false]", criteria.IsPresent{"lyrics": false},
|
|
"(media_file.lyrics IS NULL OR media_file.lyrics = ? OR media_file.lyrics = ?)", "", "[]"),
|
|
// isMissing/isPresent — nullable numeric columns (BPM, BitDepth)
|
|
Entry("isMissing bpm [true]", criteria.IsMissing{"bpm": true},
|
|
"(media_file.bpm IS NULL)"),
|
|
Entry("isPresent bpm [true]", criteria.IsPresent{"bpm": true},
|
|
"(media_file.bpm IS NOT NULL)"),
|
|
Entry("isMissing bitdepth [true]", criteria.IsMissing{"bitdepth": true},
|
|
"(media_file.bit_depth IS NULL)"),
|
|
Entry("isPresent bitdepth [false]", criteria.IsPresent{"bitdepth": false},
|
|
"(media_file.bit_depth IS NULL)"),
|
|
// isMissing/isPresent — more string column fields (empty string means missing)
|
|
Entry("isMissing album [true]", criteria.IsMissing{"album": true},
|
|
"(media_file.album IS NULL OR media_file.album = ?)", ""),
|
|
Entry("isMissing comment [true]", criteria.IsMissing{"comment": true},
|
|
"(media_file.comment IS NULL OR media_file.comment = ?)", ""),
|
|
Entry("isMissing catalognumber [true]", criteria.IsMissing{"catalognumber": true},
|
|
"(media_file.catalog_num IS NULL OR media_file.catalog_num = ?)", ""),
|
|
Entry("isMissing discsubtitle [true]", criteria.IsMissing{"discsubtitle": true},
|
|
"(media_file.disc_subtitle IS NULL OR media_file.disc_subtitle = ?)", ""),
|
|
Entry("isMissing albumcomment [true]", criteria.IsMissing{"albumcomment": true},
|
|
"(media_file.mbz_album_comment IS NULL OR media_file.mbz_album_comment = ?)", ""),
|
|
Entry("isMissing sorttitle [true]", criteria.IsMissing{"sorttitle": true},
|
|
"(media_file.sort_title IS NULL OR media_file.sort_title = ?)", ""),
|
|
Entry("isMissing sortalbum [true]", criteria.IsMissing{"sortalbum": true},
|
|
"(media_file.sort_album_name IS NULL OR media_file.sort_album_name = ?)", ""),
|
|
Entry("isMissing sortartist [true]", criteria.IsMissing{"sortartist": true},
|
|
"(media_file.sort_artist_name IS NULL OR media_file.sort_artist_name = ?)", ""),
|
|
Entry("isMissing sortalbumartist [true]", criteria.IsMissing{"sortalbumartist": true},
|
|
"(media_file.sort_album_artist_name IS NULL OR media_file.sort_album_artist_name = ?)", ""),
|
|
Entry("isMissing explicitstatus [true]", criteria.IsMissing{"explicitstatus": true},
|
|
"(media_file.explicit_status IS NULL OR media_file.explicit_status = ?)", ""),
|
|
Entry("isPresent comment [true]", criteria.IsPresent{"comment": true},
|
|
"(media_file.comment IS NOT NULL AND media_file.comment <> ?)", ""),
|
|
)
|
|
|
|
Describe("playlist permissions", func() {
|
|
It("allows public or same-owner playlist references for regular users", func() {
|
|
sqlizer, err := newSmartPlaylistCriteria(
|
|
criteria.Criteria{Expression: criteria.InPlaylist{"id": "deadbeef-dead-beef"}},
|
|
withSmartPlaylistOwner(model.User{ID: "owner-id", IsAdmin: false}),
|
|
).where()
|
|
Expect(err).ToNot(HaveOccurred())
|
|
|
|
sql, args, err := sqlizer.ToSql()
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(sql).To(Equal("media_file.id IN (SELECT media_file_id FROM playlist_tracks pl LEFT JOIN playlist on pl.playlist_id = playlist.id WHERE (pl.playlist_id = ? AND (playlist.public = ? OR playlist.owner_id = ?)))"))
|
|
Expect(args).To(HaveExactElements("deadbeef-dead-beef", 1, "owner-id"))
|
|
})
|
|
|
|
It("allows all playlist references for admins", func() {
|
|
sqlizer, err := newSmartPlaylistCriteria(
|
|
criteria.Criteria{Expression: criteria.InPlaylist{"id": "deadbeef-dead-beef"}},
|
|
withSmartPlaylistOwner(model.User{ID: "admin-id", IsAdmin: true}),
|
|
).where()
|
|
Expect(err).ToNot(HaveOccurred())
|
|
|
|
sql, args, err := sqlizer.ToSql()
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(sql).To(Equal("media_file.id IN (SELECT media_file_id FROM playlist_tracks pl LEFT JOIN playlist on pl.playlist_id = playlist.id WHERE (pl.playlist_id = ?))"))
|
|
Expect(args).To(HaveExactElements("deadbeef-dead-beef"))
|
|
})
|
|
})
|
|
|
|
It("builds relative date expressions", func() {
|
|
sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: criteria.InTheLast{"lastPlayed": 30}}).where()
|
|
Expect(err).ToNot(HaveOccurred())
|
|
|
|
sql, args, err := sqlizer.ToSql()
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(sql).To(Equal("annotation.play_date > ?"))
|
|
Expect(args).To(HaveExactElements(startOfPeriod(30, time.Now())))
|
|
})
|
|
|
|
It("builds negated relative date expressions", func() {
|
|
sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: criteria.NotInTheLast{"lastPlayed": 30}}).where()
|
|
Expect(err).ToNot(HaveOccurred())
|
|
|
|
sql, args, err := sqlizer.ToSql()
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(sql).To(Equal("(annotation.play_date < ? OR annotation.play_date IS NULL)"))
|
|
Expect(args).To(HaveExactElements(startOfPeriod(30, time.Now())))
|
|
})
|
|
|
|
It("returns an error for unknown fields", func() {
|
|
_, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: criteria.EndsWith{"unknown": "value"}}).where()
|
|
|
|
Expect(err).To(MatchError("invalid field in criteria: unknown"))
|
|
})
|
|
|
|
It("returns an error when isMissing is used with a regular field", func() {
|
|
_, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: criteria.IsMissing{"year": true}}).where()
|
|
Expect(err).To(MatchError(ContainSubstring("isMissing/isPresent operator is not supported for field")))
|
|
})
|
|
|
|
It("returns an error when isPresent is used with a regular field", func() {
|
|
_, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: criteria.IsPresent{"title": true}}).where()
|
|
Expect(err).To(MatchError(ContainSubstring("isMissing/isPresent operator is not supported for field")))
|
|
})
|
|
|
|
It("returns an error when isMissing has a non-boolean value", func() {
|
|
_, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: criteria.IsMissing{"genre": "hello"}}).where()
|
|
Expect(err).To(MatchError(ContainSubstring("invalid boolean value for 'missing' expression")))
|
|
})
|
|
|
|
It("returns an error when inPlaylist has empty path", func() {
|
|
_, err := newSmartPlaylistCriteria(
|
|
criteria.Criteria{Expression: criteria.InPlaylist{"path": ""}},
|
|
withSmartPlaylistOwner(model.User{ID: "owner-id", IsAdmin: false})).where()
|
|
Expect(err).To(MatchError(ContainSubstring("playlist id or path not given")))
|
|
})
|
|
|
|
It("returns an error for a range over a tag/role field", func() {
|
|
_, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: criteria.InTheRange{"rate": []int{1, 5}}}).where()
|
|
Expect(err).To(MatchError(ContainSubstring("range operator not supported for tag/role field")))
|
|
})
|
|
|
|
It("returns a clear error for a malformed range value", func() {
|
|
_, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: criteria.InTheRange{"playCount": []int{1, 2, 3}}}).where()
|
|
Expect(err).To(MatchError(ContainSubstring("must be a [min, max] pair")))
|
|
})
|
|
|
|
Describe("sort", func() {
|
|
It("sorts by regular fields", func() {
|
|
Expect(newSmartPlaylistCriteria(criteria.Criteria{Sort: "title"}).orderBy()).To(Equal("media_file.title asc"))
|
|
})
|
|
|
|
It("sorts by tag fields", func() {
|
|
Expect(newSmartPlaylistCriteria(criteria.Criteria{Sort: "genre"}).orderBy()).To(Equal("COALESCE(json_extract(media_file.tags, '$.genre[0].value'), '') asc"))
|
|
})
|
|
|
|
It("sorts by role fields", func() {
|
|
Expect(newSmartPlaylistCriteria(criteria.Criteria{Sort: "artist"}).orderBy()).To(Equal("COALESCE(json_extract(media_file.participants, '$.artist[0].name'), '') asc"))
|
|
})
|
|
|
|
It("casts numeric tags when sorting", func() {
|
|
Expect(newSmartPlaylistCriteria(criteria.Criteria{Sort: "rate"}).orderBy()).To(Equal("CAST(COALESCE(json_extract(media_file.tags, '$.rate[0].value'), '') AS REAL) asc"))
|
|
})
|
|
|
|
It("sorts by albumtype alias", func() {
|
|
Expect(newSmartPlaylistCriteria(criteria.Criteria{Sort: "albumtype"}).orderBy()).To(Equal("COALESCE(json_extract(media_file.tags, '$.releasetype[0].value'), '') asc"))
|
|
})
|
|
|
|
It("sorts by random", func() {
|
|
Expect(newSmartPlaylistCriteria(criteria.Criteria{Sort: "random"}).orderBy()).To(Equal("random() asc"))
|
|
})
|
|
|
|
It("sorts by album columns bare, with no COALESCE default", func() {
|
|
Expect(newSmartPlaylistCriteria(criteria.Criteria{Sort: "-albumDateAdded,trackNumber"}).orderBy()).
|
|
To(Equal("album.created_at desc, media_file.track_number asc"))
|
|
})
|
|
|
|
It("sorts by multiple fields", func() {
|
|
Expect(newSmartPlaylistCriteria(criteria.Criteria{Sort: "title,-rating"}).orderBy()).To(Equal("media_file.title asc, COALESCE(annotation.rating, 0) desc"))
|
|
})
|
|
|
|
It("reverts order when order is desc", func() {
|
|
Expect(newSmartPlaylistCriteria(criteria.Criteria{Sort: "-date,artist", Order: "desc"}).orderBy()).To(Equal("media_file.date asc, COALESCE(json_extract(media_file.participants, '$.artist[0].name'), '') desc"))
|
|
})
|
|
|
|
It("ignores invalid sort fields", func() {
|
|
Expect(newSmartPlaylistCriteria(criteria.Criteria{Sort: "bogus,title"}).orderBy()).To(Equal("media_file.title asc"))
|
|
})
|
|
})
|
|
|
|
It("has SQL mappings for all non-tag/non-role criteria fields", func() {
|
|
for _, name := range criteria.AllFieldNames() {
|
|
info, ok := criteria.LookupField(name)
|
|
Expect(ok).To(BeTrue(), "field %q registered but LookupField fails", name)
|
|
if info.IsTag || info.IsRole {
|
|
continue
|
|
}
|
|
_, hasSQLField := smartPlaylistFields[info.Name()]
|
|
Expect(hasSQLField).To(BeTrue(), "criteria field %q (name=%q) has no entry in smartPlaylistFields", name, info.Name())
|
|
}
|
|
})
|
|
|
|
It("declares a joinType matching the table each field selects from", func() {
|
|
// Omitting the joinType still compiles, so without this the field would only fail at
|
|
// refresh time with "no such column".
|
|
joinByTable := map[string]smartPlaylistJoinType{
|
|
"media_file": smartPlaylistJoinNone,
|
|
"annotation": smartPlaylistJoinNone,
|
|
"album": smartPlaylistJoinAlbum,
|
|
"album_annotation": smartPlaylistJoinAlbumAnnotation,
|
|
"artist_annotation": smartPlaylistJoinArtistAnnotation,
|
|
}
|
|
for name, field := range smartPlaylistFields {
|
|
if field.expr == "" {
|
|
continue
|
|
}
|
|
table, _, ok := strings.Cut(field.expr, ".")
|
|
Expect(ok).To(BeTrue(), "field %q has expr %q with no table prefix", name, field.expr)
|
|
want, known := joinByTable[table]
|
|
Expect(known).To(BeTrue(), "field %q selects from unknown table %q", name, table)
|
|
Expect(field.joinType).To(Equal(want), "field %q selects from %q but declares the wrong joinType", name, table)
|
|
}
|
|
})
|
|
|
|
Describe("JSON condition merging", func() {
|
|
It("merges multiple role conditions in an OR group into a single EXISTS", func() {
|
|
expr := criteria.Any{
|
|
criteria.Contains{"artist": "Beatles"},
|
|
criteria.Contains{"artist": "Kraftwerk"},
|
|
criteria.Contains{"artist": "Pink Floyd"},
|
|
}
|
|
sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: expr}).where()
|
|
Expect(err).ToNot(HaveOccurred())
|
|
|
|
sql, args, err := sqlizer.ToSql()
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(sql).To(Equal("(exists (select 1 from media_file_artists mfa join artist on artist.id = mfa.artist_id where mfa.media_file_id = media_file.id and mfa.role = ? and (artist.name LIKE ? OR artist.name LIKE ? OR artist.name LIKE ?)))"))
|
|
Expect(args).To(HaveExactElements("artist", "%Beatles%", "%Kraftwerk%", "%Pink Floyd%"))
|
|
})
|
|
|
|
It("does not merge role conditions from different roles", func() {
|
|
expr := criteria.Any{
|
|
criteria.Contains{"artist": "Beatles"},
|
|
criteria.Contains{"composer": "Lennon"},
|
|
}
|
|
sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: expr}).where()
|
|
Expect(err).ToNot(HaveOccurred())
|
|
|
|
sql, _, err := sqlizer.ToSql()
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(sql).To(ContainSubstring("mfa.role = ?"))
|
|
// Two separate EXISTS since roles differ
|
|
Expect(strings.Count(sql, "exists")).To(Equal(2))
|
|
})
|
|
|
|
It("does not merge negated role conditions", func() {
|
|
expr := criteria.Any{
|
|
criteria.NotContains{"artist": "Beatles"},
|
|
criteria.NotContains{"artist": "Kraftwerk"},
|
|
}
|
|
sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: expr}).where()
|
|
Expect(err).ToNot(HaveOccurred())
|
|
|
|
sql, _, err := sqlizer.ToSql()
|
|
Expect(err).ToNot(HaveOccurred())
|
|
// Two separate "not exists" since they are negated
|
|
Expect(strings.Count(sql, "not exists")).To(Equal(2))
|
|
})
|
|
|
|
It("batches large groups to avoid SQLite expression tree depth limit", func() {
|
|
// Create jsonCondBatchSize + 1 conditions to trigger batching into 2 groups
|
|
anyExprs := make(criteria.Any, jsonCondBatchSize+1)
|
|
for i := range anyExprs {
|
|
anyExprs[i] = criteria.Contains{"artist": fmt.Sprintf("Artist%d", i)}
|
|
}
|
|
sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: anyExprs}).where()
|
|
Expect(err).ToNot(HaveOccurred())
|
|
|
|
sql, args, err := sqlizer.ToSql()
|
|
Expect(err).ToNot(HaveOccurred())
|
|
// Should produce 2 EXISTS subqueries (one batch of jsonCondBatchSize, one of 1)
|
|
Expect(strings.Count(sql, "exists")).To(Equal(2))
|
|
// First batch has jsonCondBatchSize patterns, second has 1 => total args:
|
|
// 2 roles + (jsonCondBatchSize + 1) patterns
|
|
Expect(args).To(HaveLen(2 + jsonCondBatchSize + 1))
|
|
})
|
|
|
|
It("merges role conditions while preserving non-role conditions", func() {
|
|
expr := criteria.Any{
|
|
criteria.Contains{"title": "Love"},
|
|
criteria.Contains{"artist": "Beatles"},
|
|
criteria.Contains{"artist": "Kraftwerk"},
|
|
}
|
|
sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: expr}).where()
|
|
Expect(err).ToNot(HaveOccurred())
|
|
|
|
sql, args, err := sqlizer.ToSql()
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(sql).To(ContainSubstring("media_file.title LIKE ?"))
|
|
Expect(sql).To(ContainSubstring("artist.name LIKE ? OR artist.name LIKE ?"))
|
|
Expect(args).To(HaveExactElements("%Love%", "artist", "%Beatles%", "%Kraftwerk%"))
|
|
})
|
|
|
|
It("merges multiple tag conditions in an OR group into a single EXISTS", func() {
|
|
expr := criteria.Any{
|
|
criteria.Contains{"genre": "Rock"},
|
|
criteria.Contains{"genre": "Metal"},
|
|
criteria.Contains{"genre": "Punk"},
|
|
}
|
|
sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: expr}).where()
|
|
Expect(err).ToNot(HaveOccurred())
|
|
|
|
sql, args, err := sqlizer.ToSql()
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(sql).To(Equal("(exists (select 1 from json_tree(media_file.tags, '$.genre') where key='value' and (value LIKE ? OR value LIKE ? OR value LIKE ?)))"))
|
|
Expect(args).To(HaveExactElements("%Rock%", "%Metal%", "%Punk%"))
|
|
})
|
|
|
|
It("does not merge tag conditions from different tags", func() {
|
|
expr := criteria.Any{
|
|
criteria.Contains{"genre": "Rock"},
|
|
criteria.Contains{"mood": "Happy"},
|
|
}
|
|
sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: expr}).where()
|
|
Expect(err).ToNot(HaveOccurred())
|
|
|
|
sql, _, err := sqlizer.ToSql()
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(strings.Count(sql, "exists")).To(Equal(2))
|
|
})
|
|
|
|
It("does not merge negated tag conditions", func() {
|
|
expr := criteria.Any{
|
|
criteria.NotContains{"genre": "Rock"},
|
|
criteria.NotContains{"genre": "Metal"},
|
|
}
|
|
sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: expr}).where()
|
|
Expect(err).ToNot(HaveOccurred())
|
|
|
|
sql, _, err := sqlizer.ToSql()
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(strings.Count(sql, "not exists")).To(Equal(2))
|
|
})
|
|
|
|
It("merges role and tag conditions independently", func() {
|
|
expr := criteria.Any{
|
|
criteria.Contains{"artist": "Beatles"},
|
|
criteria.Contains{"artist": "Kraftwerk"},
|
|
criteria.Contains{"genre": "Rock"},
|
|
criteria.Contains{"genre": "Metal"},
|
|
}
|
|
sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: expr}).where()
|
|
Expect(err).ToNot(HaveOccurred())
|
|
|
|
sql, args, err := sqlizer.ToSql()
|
|
Expect(err).ToNot(HaveOccurred())
|
|
// Two merged EXISTS: one for roles, one for tags
|
|
Expect(strings.Count(sql, "exists")).To(Equal(2))
|
|
Expect(sql).To(ContainSubstring("artist.name LIKE ? OR artist.name LIKE ?"))
|
|
Expect(sql).To(ContainSubstring("value LIKE ? OR value LIKE ?"))
|
|
Expect(args).To(HaveLen(2 + 2 + 1)) // 2 tag patterns + 2 role patterns + 1 role name
|
|
})
|
|
|
|
It("merges negated role conditions in an AND group into a single NOT EXISTS", func() {
|
|
expr := criteria.All{
|
|
criteria.IsNot{"artist": "Beatles"},
|
|
criteria.IsNot{"artist": "Kraftwerk"},
|
|
}
|
|
sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: expr}).where()
|
|
Expect(err).ToNot(HaveOccurred())
|
|
|
|
sql, args, err := sqlizer.ToSql()
|
|
Expect(err).ToNot(HaveOccurred())
|
|
// A single NOT EXISTS with both names ORed inside (De Morgan)
|
|
Expect(strings.Count(sql, "not exists")).To(Equal(1))
|
|
Expect(sql).To(ContainSubstring("artist.name = ? OR artist.name = ?"))
|
|
Expect(args).To(HaveExactElements("artist", "Beatles", "Kraftwerk"))
|
|
})
|
|
|
|
It("merges negated notContains role conditions in an AND group", func() {
|
|
expr := criteria.All{
|
|
criteria.NotContains{"artist": "Beatles"},
|
|
criteria.NotContains{"artist": "Kraftwerk"},
|
|
}
|
|
sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: expr}).where()
|
|
Expect(err).ToNot(HaveOccurred())
|
|
|
|
sql, args, err := sqlizer.ToSql()
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(strings.Count(sql, "not exists")).To(Equal(1))
|
|
Expect(sql).To(ContainSubstring("artist.name LIKE ? OR artist.name LIKE ?"))
|
|
Expect(args).To(HaveExactElements("artist", "%Beatles%", "%Kraftwerk%"))
|
|
})
|
|
|
|
It("merges negated tag conditions in an AND group into a single NOT EXISTS", func() {
|
|
expr := criteria.All{
|
|
criteria.NotContains{"genre": "Rock"},
|
|
criteria.NotContains{"genre": "Metal"},
|
|
}
|
|
sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: expr}).where()
|
|
Expect(err).ToNot(HaveOccurred())
|
|
|
|
sql, args, err := sqlizer.ToSql()
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(strings.Count(sql, "not exists")).To(Equal(1))
|
|
Expect(sql).To(ContainSubstring("value LIKE ? OR value LIKE ?"))
|
|
Expect(args).To(HaveExactElements("%Rock%", "%Metal%"))
|
|
})
|
|
|
|
It("does not merge a single negated condition with a positive one of the same role in AND", func() {
|
|
// AND of mixed polarity must not be collapsed: NOT EXISTS(a) AND EXISTS(b)
|
|
// is not equivalent to any single merged subquery.
|
|
expr := criteria.All{
|
|
criteria.Contains{"artist": "Beatles"},
|
|
criteria.IsNot{"artist": "Kraftwerk"},
|
|
}
|
|
sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: expr}).where()
|
|
Expect(err).ToNot(HaveOccurred())
|
|
|
|
sql, _, err := sqlizer.ToSql()
|
|
Expect(err).ToNot(HaveOccurred())
|
|
// One positive EXISTS and one negated NOT EXISTS, kept separate
|
|
Expect(strings.Count(sql, "not exists")).To(Equal(1))
|
|
Expect(strings.Count(sql, "exists")).To(Equal(2)) // "not exists" contains "exists"
|
|
})
|
|
|
|
It("does not merge negated conditions of different roles in AND", func() {
|
|
expr := criteria.All{
|
|
criteria.IsNot{"artist": "Beatles"},
|
|
criteria.IsNot{"composer": "Lennon"},
|
|
}
|
|
sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: expr}).where()
|
|
Expect(err).ToNot(HaveOccurred())
|
|
|
|
sql, _, err := sqlizer.ToSql()
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(strings.Count(sql, "not exists")).To(Equal(2))
|
|
})
|
|
|
|
It("batches large negated AND groups to avoid SQLite expression tree depth limit", func() {
|
|
allExprs := make(criteria.All, jsonCondBatchSize+1)
|
|
for i := range allExprs {
|
|
allExprs[i] = criteria.IsNot{"artist": fmt.Sprintf("Artist%d", i)}
|
|
}
|
|
sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: allExprs}).where()
|
|
Expect(err).ToNot(HaveOccurred())
|
|
|
|
sql, args, err := sqlizer.ToSql()
|
|
Expect(err).ToNot(HaveOccurred())
|
|
// Two NOT EXISTS subqueries (one batch of jsonCondBatchSize, one of 1)
|
|
Expect(strings.Count(sql, "not exists")).To(Equal(2))
|
|
Expect(args).To(HaveLen(2 + jsonCondBatchSize + 1))
|
|
})
|
|
})
|
|
|
|
Describe("joins", func() {
|
|
It("excludes sort-only joins from expression joins", func() {
|
|
c := criteria.Criteria{Expression: criteria.All{criteria.Contains{"title": "love"}}, Sort: "albumRating"}
|
|
cSQL := newSmartPlaylistCriteria(c)
|
|
|
|
Expect(cSQL.expressionJoins()).To(Equal(smartPlaylistJoinNone))
|
|
Expect(cSQL.requiredJoins().has(smartPlaylistJoinAlbumAnnotation)).To(BeTrue())
|
|
})
|
|
|
|
It("includes expression-based joins", func() {
|
|
c := criteria.Criteria{Expression: criteria.All{criteria.Gt{"albumRating": 3}}}
|
|
|
|
Expect(newSmartPlaylistCriteria(c).expressionJoins().has(smartPlaylistJoinAlbumAnnotation)).To(BeTrue())
|
|
})
|
|
|
|
It("detects nested album and artist joins", func() {
|
|
c := criteria.Criteria{Expression: criteria.All{
|
|
criteria.Any{criteria.All{criteria.Is{"albumLoved": true}}},
|
|
criteria.Any{criteria.Gt{"artistPlayCount": 10}},
|
|
}}
|
|
|
|
joins := newSmartPlaylistCriteria(c).requiredJoins()
|
|
Expect(joins.has(smartPlaylistJoinAlbumAnnotation)).To(BeTrue())
|
|
Expect(joins.has(smartPlaylistJoinArtistAnnotation)).To(BeTrue())
|
|
})
|
|
|
|
It("detects join types from sort fields with direction prefixes", func() {
|
|
c := criteria.Criteria{Expression: criteria.All{criteria.Contains{"title": "love"}}, Sort: "-artistRating"}
|
|
|
|
Expect(newSmartPlaylistCriteria(c).requiredJoins().has(smartPlaylistJoinArtistAnnotation)).To(BeTrue())
|
|
})
|
|
|
|
It("keeps a sort-only album join out of the expression joins", func() {
|
|
c := criteria.Criteria{Expression: criteria.All{criteria.Contains{"title": "love"}}, Sort: "-albumDateAdded"}
|
|
cSQL := newSmartPlaylistCriteria(c)
|
|
|
|
Expect(cSQL.expressionJoins()).To(Equal(smartPlaylistJoinNone))
|
|
Expect(cSQL.requiredJoins().has(smartPlaylistJoinAlbum)).To(BeTrue())
|
|
})
|
|
|
|
It("distinguishes the album join from the album annotation join", func() {
|
|
c := criteria.Criteria{Expression: criteria.All{criteria.Gt{"albumRating": 3}}}
|
|
joins := newSmartPlaylistCriteria(c).requiredJoins()
|
|
|
|
Expect(joins.has(smartPlaylistJoinAlbumAnnotation)).To(BeTrue())
|
|
Expect(joins.has(smartPlaylistJoinAlbum)).To(BeFalse())
|
|
})
|
|
})
|
|
})
|