feat: make album and artist annotations available to smart playlists (#4927)
* feat(criteria): make album ratings available to smart playlist queries Expose an "albumrating" field mapping to album annotations. Signed-off-by: Valeri Sokolov <ulfurinn@ulfurinn.net> * fix(criteria): use query parameters Signed-off-by: Valeri Sokolov <ulfurinn@ulfurinn.net> * feat: add album and artist annotation fields to smart playlists Extend smart playlists to filter songs by album or artist annotations (rating, loved, play count, last played, date loved, date rated). This adds 12 new fields (6 album, 6 artist) with conditional JOINs that are only added when the criteria or sort references them, avoiding unnecessary query overhead. The album table JOIN is also removed since media_file.album_id can be used directly. --------- Signed-off-by: Valeri Sokolov <ulfurinn@ulfurinn.net> Co-authored-by: Deluan <deluan@navidrome.org>
This commit is contained in:
@@ -241,10 +241,25 @@ func (r *playlistRepository) refreshSmartPlaylist(pls *model.Playlist) bool {
|
||||
}
|
||||
|
||||
sq := Select("row_number() over (order by "+rules.OrderBy()+") as id", "'"+pls.ID+"' as playlist_id", "media_file.id as media_file_id").
|
||||
From("media_file").LeftJoin("annotation on (" +
|
||||
"annotation.item_id = media_file.id" +
|
||||
" AND annotation.item_type = 'media_file'" +
|
||||
" AND annotation.user_id = '" + usr.ID + "')")
|
||||
From("media_file").LeftJoin("annotation on ("+
|
||||
"annotation.item_id = media_file.id"+
|
||||
" AND annotation.item_type = 'media_file'"+
|
||||
" AND annotation.user_id = ?)", usr.ID)
|
||||
|
||||
// Conditionally join album/artist annotation tables only when referenced by criteria or sort
|
||||
requiredJoins := rules.RequiredJoins()
|
||||
if requiredJoins.Has(criteria.JoinAlbumAnnotation) {
|
||||
sq = sq.LeftJoin("annotation AS album_annotation ON ("+
|
||||
"album_annotation.item_id = media_file.album_id"+
|
||||
" AND album_annotation.item_type = 'album'"+
|
||||
" AND album_annotation.user_id = ?)", usr.ID)
|
||||
}
|
||||
if requiredJoins.Has(criteria.JoinArtistAnnotation) {
|
||||
sq = sq.LeftJoin("annotation AS artist_annotation ON ("+
|
||||
"artist_annotation.item_id = media_file.artist_id"+
|
||||
" AND artist_annotation.item_type = 'artist'"+
|
||||
" AND artist_annotation.user_id = ?)", usr.ID)
|
||||
}
|
||||
|
||||
// Only include media files from libraries the user has access to
|
||||
sq = r.applyLibraryFilter(sq, "media_file")
|
||||
|
||||
@@ -287,6 +287,106 @@ var _ = Describe("PlaylistRepository", func() {
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Smart Playlists with Album/Artist Annotation Criteria", func() {
|
||||
var testPlaylistID string
|
||||
|
||||
AfterEach(func() {
|
||||
if testPlaylistID != "" {
|
||||
_ = repo.Delete(testPlaylistID)
|
||||
testPlaylistID = ""
|
||||
}
|
||||
})
|
||||
|
||||
It("matches tracks from starred albums using albumLoved", func() {
|
||||
// albumRadioactivity (ID "103") is starred in test fixtures
|
||||
// Songs in album 103: 1003, 1004, 1005, 1006
|
||||
rules := &criteria.Criteria{
|
||||
Expression: criteria.All{
|
||||
criteria.Is{"albumLoved": true},
|
||||
},
|
||||
}
|
||||
newPls := model.Playlist{Name: "Starred Album Songs", OwnerID: "userid", Rules: rules}
|
||||
Expect(repo.Put(&newPls)).To(Succeed())
|
||||
testPlaylistID = newPls.ID
|
||||
|
||||
conf.Server.SmartPlaylistRefreshDelay = -1 * time.Second
|
||||
pls, err := repo.GetWithTracks(newPls.ID, true, false)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
trackIDs := make([]string, len(pls.Tracks))
|
||||
for i, t := range pls.Tracks {
|
||||
trackIDs[i] = t.MediaFileID
|
||||
}
|
||||
Expect(trackIDs).To(ConsistOf("1003", "1004", "1005", "1006"))
|
||||
})
|
||||
|
||||
It("matches tracks from starred artists using artistLoved", func() {
|
||||
// artistBeatles (ID "3") is starred in test fixtures
|
||||
// Songs with ArtistID "3": 1001, 1002, 3002
|
||||
rules := &criteria.Criteria{
|
||||
Expression: criteria.All{
|
||||
criteria.Is{"artistLoved": true},
|
||||
},
|
||||
}
|
||||
newPls := model.Playlist{Name: "Starred Artist Songs", OwnerID: "userid", Rules: rules}
|
||||
Expect(repo.Put(&newPls)).To(Succeed())
|
||||
testPlaylistID = newPls.ID
|
||||
|
||||
conf.Server.SmartPlaylistRefreshDelay = -1 * time.Second
|
||||
pls, err := repo.GetWithTracks(newPls.ID, true, false)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
trackIDs := make([]string, len(pls.Tracks))
|
||||
for i, t := range pls.Tracks {
|
||||
trackIDs[i] = t.MediaFileID
|
||||
}
|
||||
Expect(trackIDs).To(ConsistOf("1001", "1002", "3002"))
|
||||
})
|
||||
|
||||
It("matches tracks with combined album and artist criteria", func() {
|
||||
// albumLoved=true → songs from album 103 (1003, 1004, 1005, 1006)
|
||||
// artistLoved=true → songs with artist 3 (1001, 1002)
|
||||
// Using Any: union of both sets
|
||||
rules := &criteria.Criteria{
|
||||
Expression: criteria.Any{
|
||||
criteria.Is{"albumLoved": true},
|
||||
criteria.Is{"artistLoved": true},
|
||||
},
|
||||
}
|
||||
newPls := model.Playlist{Name: "Combined Album+Artist", OwnerID: "userid", Rules: rules}
|
||||
Expect(repo.Put(&newPls)).To(Succeed())
|
||||
testPlaylistID = newPls.ID
|
||||
|
||||
conf.Server.SmartPlaylistRefreshDelay = -1 * time.Second
|
||||
pls, err := repo.GetWithTracks(newPls.ID, true, false)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
trackIDs := make([]string, len(pls.Tracks))
|
||||
for i, t := range pls.Tracks {
|
||||
trackIDs[i] = t.MediaFileID
|
||||
}
|
||||
Expect(trackIDs).To(ConsistOf("1001", "1002", "1003", "1004", "1005", "1006", "3002"))
|
||||
})
|
||||
|
||||
It("returns no tracks when no albums/artists match", func() {
|
||||
// No album has rating 5 in fixtures
|
||||
rules := &criteria.Criteria{
|
||||
Expression: criteria.All{
|
||||
criteria.Is{"albumRating": 5},
|
||||
},
|
||||
}
|
||||
newPls := model.Playlist{Name: "No Match", OwnerID: "userid", Rules: rules}
|
||||
Expect(repo.Put(&newPls)).To(Succeed())
|
||||
testPlaylistID = newPls.ID
|
||||
|
||||
conf.Server.SmartPlaylistRefreshDelay = -1 * time.Second
|
||||
pls, err := repo.GetWithTracks(newPls.ID, true, false)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
Expect(pls.Tracks).To(BeEmpty())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Smart Playlists with Tag Criteria", func() {
|
||||
var mfRepo model.MediaFileRepository
|
||||
var testPlaylistID string
|
||||
|
||||
Reference in New Issue
Block a user