mirror of
https://github.com/navidrome/navidrome.git
synced 2026-08-31 07:30:32 +00:00
feat: add global smart playlist functionality
Signed-off-by: Deluan <deluan@navidrome.org>
This commit is contained in:
parent
13be8e6dfb
commit
8861eebe21
@ -168,6 +168,10 @@ func (s *playlists) parseNSP(_ context.Context, pls *model.Playlist, reader io.R
|
|||||||
if nsp.Comment != "" {
|
if nsp.Comment != "" {
|
||||||
pls.Comment = nsp.Comment
|
pls.Comment = nsp.Comment
|
||||||
}
|
}
|
||||||
|
pls.Global = nsp.Global
|
||||||
|
if nsp.Global {
|
||||||
|
pls.Public = true
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -404,12 +408,18 @@ func (s *playlists) updatePlaylist(ctx context.Context, newPls *model.Playlist)
|
|||||||
newPls.Name = pls.Name
|
newPls.Name = pls.Name
|
||||||
newPls.Comment = pls.Comment
|
newPls.Comment = pls.Comment
|
||||||
newPls.OwnerID = pls.OwnerID
|
newPls.OwnerID = pls.OwnerID
|
||||||
newPls.Public = pls.Public
|
// Preserve Public from existing playlist, unless the new playlist is Global
|
||||||
|
if !newPls.Global {
|
||||||
|
newPls.Public = pls.Public
|
||||||
|
}
|
||||||
newPls.EvaluatedAt = &time.Time{}
|
newPls.EvaluatedAt = &time.Time{}
|
||||||
} else {
|
} else {
|
||||||
log.Info(ctx, "Adding synced playlist", "playlist", newPls.Name, "path", newPls.Path, "owner", owner.UserName)
|
log.Info(ctx, "Adding synced playlist", "playlist", newPls.Name, "path", newPls.Path, "owner", owner.UserName)
|
||||||
newPls.OwnerID = owner.ID
|
newPls.OwnerID = owner.ID
|
||||||
newPls.Public = conf.Server.DefaultPlaylistPublicVisibility
|
// Only apply default visibility if not a global playlist (which is always public)
|
||||||
|
if !newPls.Global {
|
||||||
|
newPls.Public = conf.Server.DefaultPlaylistPublicVisibility
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return s.ds.Playlist(ctx).Put(newPls)
|
return s.ds.Playlist(ctx).Put(newPls)
|
||||||
}
|
}
|
||||||
@ -473,6 +483,7 @@ type nspFile struct {
|
|||||||
criteria.Criteria
|
criteria.Criteria
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Comment string `json:"comment"`
|
Comment string `json:"comment"`
|
||||||
|
Global bool `json:"global"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (i *nspFile) UnmarshalJSON(data []byte) error {
|
func (i *nspFile) UnmarshalJSON(data []byte) error {
|
||||||
@ -483,5 +494,6 @@ func (i *nspFile) UnmarshalJSON(data []byte) error {
|
|||||||
}
|
}
|
||||||
i.Name, _ = m["name"].(string)
|
i.Name, _ = m["name"].(string)
|
||||||
i.Comment, _ = m["comment"].(string)
|
i.Comment, _ = m["comment"].(string)
|
||||||
|
i.Global, _ = m["global"].(bool)
|
||||||
return json.Unmarshal(data, &i.Criteria)
|
return json.Unmarshal(data, &i.Criteria)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -107,11 +107,20 @@ var _ = Describe("Playlists", func() {
|
|||||||
Expect(pls.Rules.Order).To(Equal("desc"))
|
Expect(pls.Rules.Order).To(Equal("desc"))
|
||||||
Expect(pls.Rules.Limit).To(Equal(100))
|
Expect(pls.Rules.Limit).To(Equal(100))
|
||||||
Expect(pls.Rules.Expression).To(BeAssignableToTypeOf(criteria.All{}))
|
Expect(pls.Rules.Expression).To(BeAssignableToTypeOf(criteria.All{}))
|
||||||
|
Expect(pls.Global).To(BeFalse())
|
||||||
|
Expect(pls.Public).To(BeFalse())
|
||||||
})
|
})
|
||||||
It("returns an error if the playlist is not well-formed", func() {
|
It("returns an error if the playlist is not well-formed", func() {
|
||||||
_, err := ps.ImportFile(ctx, folder, "invalid_json.nsp")
|
_, err := ps.ImportFile(ctx, folder, "invalid_json.nsp")
|
||||||
Expect(err.Error()).To(ContainSubstring("line 19, column 1: invalid character '\\n'"))
|
Expect(err.Error()).To(ContainSubstring("line 19, column 1: invalid character '\\n'"))
|
||||||
})
|
})
|
||||||
|
It("parses global attribute and sets playlist to public", func() {
|
||||||
|
pls, err := ps.ImportFile(ctx, folder, "global_smart_playlist.nsp")
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
Expect(pls.Name).To(Equal("Global Smart Playlist"))
|
||||||
|
Expect(pls.Global).To(BeTrue())
|
||||||
|
Expect(pls.Public).To(BeTrue())
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
Describe("Cross-library relative paths", func() {
|
Describe("Cross-library relative paths", func() {
|
||||||
|
|||||||
@ -0,0 +1,9 @@
|
|||||||
|
-- +goose Up
|
||||||
|
-- +goose StatementBegin
|
||||||
|
ALTER TABLE playlist ADD COLUMN global BOOL DEFAULT FALSE NOT NULL;
|
||||||
|
-- +goose StatementEnd
|
||||||
|
|
||||||
|
-- +goose Down
|
||||||
|
-- +goose StatementBegin
|
||||||
|
ALTER TABLE playlist DROP COLUMN global;
|
||||||
|
-- +goose StatementEnd
|
||||||
@ -27,6 +27,7 @@ type Playlist struct {
|
|||||||
// SmartPlaylist attributes
|
// SmartPlaylist attributes
|
||||||
Rules *criteria.Criteria `structs:"rules" json:"rules"`
|
Rules *criteria.Criteria `structs:"rules" json:"rules"`
|
||||||
EvaluatedAt *time.Time `structs:"evaluated_at" json:"evaluatedAt"`
|
EvaluatedAt *time.Time `structs:"evaluated_at" json:"evaluatedAt"`
|
||||||
|
Global bool `structs:"global" json:"global"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (pls Playlist) IsSmartPlaylist() bool {
|
func (pls Playlist) IsSmartPlaylist() bool {
|
||||||
|
|||||||
@ -227,10 +227,10 @@ func (r *playlistRepository) refreshSmartPlaylist(pls *model.Playlist) bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// Never refresh other users' playlists
|
// Only refresh for owners, unless the playlist is marked as global
|
||||||
usr := loggedUser(r.ctx)
|
usr := loggedUser(r.ctx)
|
||||||
if pls.OwnerID != usr.ID {
|
if pls.OwnerID != usr.ID && !pls.Global {
|
||||||
log.Trace(r.ctx, "Not refreshing smart playlist from other user", "playlist", pls.Name, "id", pls.ID)
|
log.Debug(r.ctx, "Not refreshing smart playlist from other user", "playlist", pls.Name, "id", pls.ID)
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -4,6 +4,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/navidrome/navidrome/conf"
|
"github.com/navidrome/navidrome/conf"
|
||||||
|
"github.com/navidrome/navidrome/conf/configtest"
|
||||||
"github.com/navidrome/navidrome/log"
|
"github.com/navidrome/navidrome/log"
|
||||||
"github.com/navidrome/navidrome/model"
|
"github.com/navidrome/navidrome/model"
|
||||||
"github.com/navidrome/navidrome/model/criteria"
|
"github.com/navidrome/navidrome/model/criteria"
|
||||||
@ -147,6 +148,67 @@ var _ = Describe("PlaylistRepository", func() {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
Context("Global smart playlists", func() {
|
||||||
|
var globalPls model.Playlist
|
||||||
|
var otherUserRepo model.PlaylistRepository
|
||||||
|
|
||||||
|
BeforeEach(func() {
|
||||||
|
// Force smart playlist refresh
|
||||||
|
DeferCleanup(configtest.SetupConfig())
|
||||||
|
conf.Server.SmartPlaylistRefreshDelay = -1 * time.Second
|
||||||
|
|
||||||
|
// Create a global smart playlist owned by the admin user
|
||||||
|
globalPls = model.Playlist{Name: "Global Smart", OwnerID: "userid", Rules: rules, Global: true, Public: true}
|
||||||
|
Expect(repo.Put(&globalPls)).To(Succeed())
|
||||||
|
|
||||||
|
// Create a different user context (using regularUser who has library access)
|
||||||
|
otherCtx := log.NewContext(GinkgoT().Context())
|
||||||
|
otherCtx = request.WithUser(otherCtx, regularUser)
|
||||||
|
otherUserRepo = NewPlaylistRepository(otherCtx, GetDBXBuilder())
|
||||||
|
})
|
||||||
|
|
||||||
|
AfterEach(func() {
|
||||||
|
_ = repo.Delete(globalPls.ID)
|
||||||
|
})
|
||||||
|
|
||||||
|
It("stores and retrieves the Global attribute", func() {
|
||||||
|
savedPls, err := repo.Get(globalPls.ID)
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
Expect(savedPls.Global).To(BeTrue())
|
||||||
|
})
|
||||||
|
|
||||||
|
It("allows non-owner to refresh a global smart playlist", func() {
|
||||||
|
// Verify the playlist can be retrieved by non-owner and has Global=true
|
||||||
|
plsCheck, err := otherUserRepo.Get(globalPls.ID)
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
Expect(plsCheck.Global).To(BeTrue(), "Global should be true when retrieved by non-owner")
|
||||||
|
Expect(plsCheck.IsSmartPlaylist()).To(BeTrue(), "Should be smart playlist")
|
||||||
|
Expect(plsCheck.EvaluatedAt).To(BeNil(), "Should not be evaluated yet")
|
||||||
|
|
||||||
|
// Non-owner requests the playlist with refresh
|
||||||
|
_, err = otherUserRepo.GetWithTracks(globalPls.ID, true, false)
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
|
||||||
|
// Re-fetch to verify EvaluatedAt was updated in DB
|
||||||
|
pls, err := otherUserRepo.Get(globalPls.ID)
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
Expect(pls.EvaluatedAt).ToNot(BeNil(), "Global smart playlist should be refreshed for non-owner")
|
||||||
|
})
|
||||||
|
|
||||||
|
It("does not allow non-owner to refresh a non-global smart playlist", func() {
|
||||||
|
// Create a non-global smart playlist
|
||||||
|
nonGlobalPls := model.Playlist{Name: "Non-Global Smart", OwnerID: "userid", Rules: rules, Global: false, Public: true}
|
||||||
|
Expect(repo.Put(&nonGlobalPls)).To(Succeed())
|
||||||
|
DeferCleanup(func() { _ = repo.Delete(nonGlobalPls.ID) })
|
||||||
|
|
||||||
|
// Non-owner requests the playlist with refresh
|
||||||
|
pls, err := otherUserRepo.GetWithTracks(nonGlobalPls.ID, true, false)
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
// EvaluatedAt should be nil because the playlist was not refreshed
|
||||||
|
Expect(pls.EvaluatedAt).To(BeNil())
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
Context("invalid rules", func() {
|
Context("invalid rules", func() {
|
||||||
It("fails to Put it in the DB", func() {
|
It("fails to Put it in the DB", func() {
|
||||||
rules = &criteria.Criteria{
|
rules = &criteria.Criteria{
|
||||||
|
|||||||
10
tests/fixtures/playlists/global_smart_playlist.nsp
vendored
Normal file
10
tests/fixtures/playlists/global_smart_playlist.nsp
vendored
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"name": "Global Smart Playlist",
|
||||||
|
"comment": "Available for evaluation by any user",
|
||||||
|
"global": true,
|
||||||
|
"all": [
|
||||||
|
{"is": {"loved": true}}
|
||||||
|
],
|
||||||
|
"sort": "title",
|
||||||
|
"order": "asc"
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user