Merge 7fd3fc34427b482a96164f3667742bfdd38c2f4e into 7e083e0795c3b60008663925f6d0eac698e8b364

This commit is contained in:
David Vedvick 2026-04-24 17:02:22 +03:00 committed by GitHub
commit 63bd6c3b89
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 194 additions and 25 deletions

View File

@ -164,6 +164,18 @@ func (c Criteria) ChildPlaylistIds() []string {
return nil return nil
} }
func (c Criteria) ChildPlaylistPaths() []string {
if c.Expression == nil {
return nil
}
if parent := c.Expression.(interface{ ChildPlaylistPaths() (paths []string) }); parent != nil {
return parent.ChildPlaylistPaths()
}
return nil
}
func (c Criteria) MarshalJSON() ([]byte, error) { func (c Criteria) MarshalJSON() ([]byte, error) {
aux := struct { aux := struct {
All []Expression `json:"all,omitempty"` All []Expression `json:"all,omitempty"`

View File

@ -407,19 +407,23 @@ var _ = Describe("Criteria", func() {
Context("with child playlists", func() { Context("with child playlists", func() {
var ( var (
topLevelInPlaylistID string topLevelInPlaylistID string
topLevelNotInPlaylistID string topLevelInPlaylistPath string
nestedAnyInPlaylistID string topLevelNotInPlaylistID string
nestedAnyNotInPlaylistID string nestedAnyInPlaylistID string
nestedAllInPlaylistID string nestedAnyNotInPlaylistID string
nestedAllNotInPlaylistID string nestedAllInPlaylistID string
nestedAllNotInPlaylistID string
nestedAnyNotInPlaylistPath string
) )
BeforeEach(func() { BeforeEach(func() {
topLevelInPlaylistID = uuid.NewString() topLevelInPlaylistID = uuid.NewString()
topLevelInPlaylistPath = "./test.nsp"
topLevelNotInPlaylistID = uuid.NewString() topLevelNotInPlaylistID = uuid.NewString()
nestedAnyInPlaylistID = uuid.NewString() nestedAnyInPlaylistID = uuid.NewString()
nestedAnyNotInPlaylistID = uuid.NewString() nestedAnyNotInPlaylistID = uuid.NewString()
nestedAnyNotInPlaylistPath = "../not-in-playlist.m3u"
nestedAllInPlaylistID = uuid.NewString() nestedAllInPlaylistID = uuid.NewString()
nestedAllNotInPlaylistID = uuid.NewString() nestedAllNotInPlaylistID = uuid.NewString()
@ -427,10 +431,12 @@ var _ = Describe("Criteria", func() {
goObj = Criteria{ goObj = Criteria{
Expression: All{ Expression: All{
InPlaylist{"id": topLevelInPlaylistID}, InPlaylist{"id": topLevelInPlaylistID},
InPlaylist{"path": topLevelInPlaylistPath},
NotInPlaylist{"id": topLevelNotInPlaylistID}, NotInPlaylist{"id": topLevelNotInPlaylistID},
Any{ Any{
InPlaylist{"id": nestedAnyInPlaylistID}, InPlaylist{"id": nestedAnyInPlaylistID},
NotInPlaylist{"id": nestedAnyNotInPlaylistID}, NotInPlaylist{"id": nestedAnyNotInPlaylistID},
NotInPlaylist{"path": nestedAnyNotInPlaylistPath},
}, },
All{ All{
InPlaylist{"id": nestedAllInPlaylistID}, InPlaylist{"id": nestedAllInPlaylistID},
@ -443,6 +449,10 @@ var _ = Describe("Criteria", func() {
ids := goObj.ChildPlaylistIds() ids := goObj.ChildPlaylistIds()
gomega.Expect(ids).To(gomega.ConsistOf(topLevelInPlaylistID, topLevelNotInPlaylistID, nestedAnyInPlaylistID, nestedAnyNotInPlaylistID, nestedAllInPlaylistID, nestedAllNotInPlaylistID)) gomega.Expect(ids).To(gomega.ConsistOf(topLevelInPlaylistID, topLevelNotInPlaylistID, nestedAnyInPlaylistID, nestedAnyNotInPlaylistID, nestedAllInPlaylistID, nestedAllNotInPlaylistID))
}) })
It("extracts all child smart playlist paths from expression criteria", func() {
ids := goObj.ChildPlaylistPaths()
gomega.Expect(ids).To(gomega.ConsistOf(topLevelInPlaylistPath, nestedAnyNotInPlaylistPath))
})
It("extracts child smart playlist IDs from deeply nested expression", func() { It("extracts child smart playlist IDs from deeply nested expression", func() {
goObj = Criteria{ goObj = Criteria{
Expression: Any{ Expression: Any{

View File

@ -27,6 +27,10 @@ func (all All) ChildPlaylistIds() (ids []string) {
return extractPlaylistIds(all) return extractPlaylistIds(all)
} }
func (all All) ChildPlaylistPaths() (paths []string) {
return extractPlaylistPaths(all)
}
type ( type (
Any squirrel.Or Any squirrel.Or
Or = Any Or = Any
@ -44,6 +48,10 @@ func (any Any) ChildPlaylistIds() (ids []string) {
return extractPlaylistIds(any) return extractPlaylistIds(any)
} }
func (any Any) ChildPlaylistPaths() (paths []string) {
return extractPlaylistPaths(any)
}
type Is squirrel.Eq type Is squirrel.Eq
type Eq = Is type Eq = Is
@ -300,10 +308,13 @@ func (ipl NotInPlaylist) MarshalJSON() ([]byte, error) {
} }
func inList(m map[string]any, negate bool) (sql string, args []any, err error) { func inList(m map[string]any, negate bool) (sql string, args []any, err error) {
var playlistid string var condition squirrel.Sqlizer
var ok bool if playlistId, ok := m["id"].(string); ok {
if playlistid, ok = m["id"].(string); !ok { condition = squirrel.Eq{"pl.playlist_id": playlistId}
return "", nil, errors.New("playlist id not given") } else if playlistPath, ok := m["path"].(string); ok {
condition = squirrel.Eq{"playlist.path": playlistPath}
} else {
return "", nil, errors.New("playlist id or path not given")
} }
// Subquery to fetch all media files that are contained in given playlist // Subquery to fetch all media files that are contained in given playlist
@ -312,8 +323,9 @@ func inList(m map[string]any, negate bool) (sql string, args []any, err error) {
From("playlist_tracks pl"). From("playlist_tracks pl").
LeftJoin("playlist on pl.playlist_id = playlist.id"). LeftJoin("playlist on pl.playlist_id = playlist.id").
Where(squirrel.And{ Where(squirrel.And{
squirrel.Eq{"pl.playlist_id": playlistid}, condition,
squirrel.Eq{"playlist.public": 1}}) squirrel.Eq{"playlist.public": 1}})
subQText, subQArgs, err := subQuery.PlaceholderFormat(squirrel.Question).ToSql() subQText, subQArgs, err := subQuery.PlaceholderFormat(squirrel.Question).ToSql()
if err != nil { if err != nil {
@ -321,33 +333,37 @@ func inList(m map[string]any, negate bool) (sql string, args []any, err error) {
} }
if negate { if negate {
return "media_file.id NOT IN (" + subQText + ")", subQArgs, nil return "media_file.id NOT IN (" + subQText + ")", subQArgs, nil
} else {
return "media_file.id IN (" + subQText + ")", subQArgs, nil
} }
return "media_file.id IN (" + subQText + ")", subQArgs, nil
} }
func extractPlaylistIds(inputRule any) (ids []string) { func extractPlaylistField(inputRule any, field string) (values []string) {
var id string
var ok bool
switch rule := inputRule.(type) { switch rule := inputRule.(type) {
case Any: case Any:
for _, rules := range rule { for _, rules := range rule {
ids = append(ids, extractPlaylistIds(rules)...) values = append(values, extractPlaylistField(rules, field)...)
} }
case All: case All:
for _, rules := range rule { for _, rules := range rule {
ids = append(ids, extractPlaylistIds(rules)...) values = append(values, extractPlaylistField(rules, field)...)
} }
case InPlaylist: case InPlaylist:
if id, ok = rule["id"].(string); ok { if value, ok := rule[field].(string); ok {
ids = append(ids, id) values = append(values, value)
} }
case NotInPlaylist: case NotInPlaylist:
if id, ok = rule["id"].(string); ok { if value, ok := rule[field].(string); ok {
ids = append(ids, id) values = append(values, value)
} }
} }
return return
} }
func extractPlaylistIds(inputRule any) (ids []string) {
return extractPlaylistField(inputRule, "id")
}
func extractPlaylistPaths(inputRule any) (paths []string) {
return extractPlaylistField(inputRule, "path")
}

View File

@ -46,8 +46,10 @@ var _ = Describe("Operators", func() {
Entry("after", After{"lastPlayed": rangeStart}, "annotation.play_date > ?", rangeStart), Entry("after", After{"lastPlayed": rangeStart}, "annotation.play_date > ?", rangeStart),
// InPlaylist and NotInPlaylist are special cases // InPlaylist and NotInPlaylist are special cases
Entry("inPlaylist", InPlaylist{"id": "deadbeef-dead-beef"}, "media_file.id IN "+ Entry("inPlaylist [id]", 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), "(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("inPlaylist [path]", 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 = ? AND playlist.public = ?))", "lacuslacus.nsp", 1),
Entry("notInPlaylist", NotInPlaylist{"id": "deadbeef-dead-beef"}, "media_file.id NOT IN "+ Entry("notInPlaylist", 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), "(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),

View File

@ -1,6 +1,7 @@
package model package model
import ( import (
"path/filepath"
"slices" "slices"
"strconv" "strconv"
"time" "time"
@ -117,6 +118,42 @@ func (pls Playlist) UploadedImagePath() string {
return UploadedImagePath(consts.EntityPlaylist, pls.UploadedImage) return UploadedImagePath(consts.EntityPlaylist, pls.UploadedImage)
} }
func (pls Playlist) NormalizeChildPaths() {
if pls.Rules.Expression == nil {
return
}
normalizePlaylistPaths(pls.Rules.Expression, pls.Path)
}
func normalizePlaylistPaths(inputRule any, referencingPlaylistPath string) {
switch rule := inputRule.(type) {
case criteria.Any:
for _, rules := range rule {
normalizePlaylistPaths(rules, referencingPlaylistPath)
}
case criteria.All:
for _, rules := range rule {
normalizePlaylistPaths(rules, referencingPlaylistPath)
}
case criteria.InPlaylist:
dir := filepath.Dir(referencingPlaylistPath)
if path, ok := rule["path"].(string); ok {
if !filepath.IsAbs(path) {
rule["path"] = filepath.Clean(filepath.Join(dir, path))
}
}
case criteria.NotInPlaylist:
dir := filepath.Dir(referencingPlaylistPath)
if path, ok := rule["path"].(string); ok {
if !filepath.IsAbs(path) {
rule["path"] = filepath.Clean(filepath.Join(dir, path))
}
}
}
return
}
type Playlists []Playlist type Playlists []Playlist
type PlaylistRepository interface { type PlaylistRepository interface {

View File

@ -2,6 +2,7 @@ package model_test
import ( import (
"github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/criteria"
"github.com/navidrome/navidrome/tests" "github.com/navidrome/navidrome/tests"
. "github.com/onsi/ginkgo/v2" . "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega" . "github.com/onsi/gomega"
@ -43,4 +44,83 @@ var _ = Describe("Playlist", func() {
Expect(pls.ToM3U8()).To(Equal(expected)) Expect(pls.ToM3U8()).To(Equal(expected))
}) })
}) })
Describe("NormalizeChildPaths()", func() {
It("normalizes file paths", func() {
pls := model.Playlist{Rules: &criteria.Criteria{
Expression: criteria.All{
criteria.InPlaylist{"path": "/test/my-test-path.m3u"},
criteria.InPlaylist{"path": "../my-test-path.m3u"},
criteria.NotInPlaylist{"path": "/not-test/not-my-test-path.m3u"},
criteria.Any{
criteria.InPlaylist{"path": "../../in-the-test.nsp"},
criteria.NotInPlaylist{"path": "./sibling.nsp"},
criteria.All{
criteria.InPlaylist{"path": "/other-root/other.m3u"},
criteria.NotInPlaylist{"path": "../../../out-of-containment.nsp"},
},
},
},
},
Path: "/test/nested/my-playlist.nsp"}
pls.NormalizeChildPaths()
Expect(pls.Rules).Should(BeEquivalentTo(&criteria.Criteria{
Expression: criteria.All{
criteria.InPlaylist{"path": "/test/my-test-path.m3u"},
criteria.InPlaylist{"path": "/test/my-test-path.m3u"},
criteria.NotInPlaylist{"path": "/not-test/not-my-test-path.m3u"},
criteria.Any{
criteria.InPlaylist{"path": "/in-the-test.nsp"},
criteria.NotInPlaylist{"path": "/test/nested/sibling.nsp"},
criteria.All{
criteria.InPlaylist{"path": "/other-root/other.m3u"},
criteria.NotInPlaylist{"path": "/out-of-containment.nsp"},
},
},
},
}))
})
It("normalizes various file paths", func() {
// Absolute path
pls := model.Playlist{ID: "123"}
pls.Rules = &criteria.Criteria{
Expression: criteria.All{
criteria.InPlaylist{"path": "/test/my-test-path.m3u"},
},
}
pls.NormalizeChildPaths()
Expect(pls.Rules).NotTo(BeNil())
})
It("handles relative paths correctly", func() {
pls := model.Playlist{ID: "123", Path: "/test/my-playlist.m3u"}
pls.Rules = &criteria.Criteria{
Expression: criteria.All{
criteria.InPlaylist{"path": "../my-test-path.m3u"},
},
}
pls.NormalizeChildPaths()
Expect(pls.Rules).Should(BeEquivalentTo(&criteria.Criteria{
Expression: criteria.All{
criteria.InPlaylist{"path": "/my-test-path.m3u"},
},
}))
})
It("ignores non-path entries", func() {
pls := model.Playlist{ID: "123"}
pls.Rules = &criteria.Criteria{
Expression: criteria.All{
criteria.InPlaylist{"path": "/not-test/not-my-test-path.m3u"},
},
}
pls.NormalizeChildPaths()
Expect(pls.Rules).NotTo(BeNil())
})
})
}) })

View File

@ -240,6 +240,18 @@ func (r *playlistRepository) refreshSmartPlaylist(pls *model.Playlist) bool {
r.refreshSmartPlaylist(childPls) r.refreshSmartPlaylist(childPls)
} }
pls.NormalizeChildPaths()
childPlaylistPaths := rules.ChildPlaylistPaths()
for _, path := range childPlaylistPaths {
log.Info(r.ctx, "Loading child playlist", "id", pls.ID, "childId", path, err)
childPls, err := r.FindByPath(path)
if err != nil {
log.Error(r.ctx, "Error loading child playlist", "id", pls.ID, "childId", path, err)
return false
}
r.refreshSmartPlaylist(childPls)
}
sq := Select("row_number() over (order by "+rules.OrderBy()+") as id", "'"+pls.ID+"' as playlist_id", "media_file.id as media_file_id"). 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 ("+ From("media_file").LeftJoin("annotation on ("+
"annotation.item_id = media_file.id"+ "annotation.item_id = media_file.id"+