mirror of
https://github.com/navidrome/navidrome.git
synced 2026-01-19 20:28:04 -05:00
Compare commits
6 Commits
fix-playli
...
global-nsp
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9465af18e4 | ||
|
|
f85c1beedb | ||
|
|
c7b93805ce | ||
|
|
8861eebe21 | ||
|
|
13be8e6dfb | ||
|
|
9ab0c2dc67 |
@@ -153,6 +153,7 @@ type subsonicOptions struct {
|
||||
ArtistParticipations bool
|
||||
DefaultReportRealPath bool
|
||||
LegacyClients string
|
||||
MinimalClients string
|
||||
}
|
||||
|
||||
type TagConf struct {
|
||||
|
||||
@@ -168,6 +168,10 @@ func (s *playlists) parseNSP(_ context.Context, pls *model.Playlist, reader io.R
|
||||
if nsp.Comment != "" {
|
||||
pls.Comment = nsp.Comment
|
||||
}
|
||||
pls.Global = nsp.Global
|
||||
if nsp.Global {
|
||||
pls.Public = true
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -404,12 +408,18 @@ func (s *playlists) updatePlaylist(ctx context.Context, newPls *model.Playlist)
|
||||
newPls.Name = pls.Name
|
||||
newPls.Comment = pls.Comment
|
||||
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{}
|
||||
} else {
|
||||
log.Info(ctx, "Adding synced playlist", "playlist", newPls.Name, "path", newPls.Path, "owner", owner.UserName)
|
||||
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)
|
||||
}
|
||||
@@ -473,6 +483,7 @@ type nspFile struct {
|
||||
criteria.Criteria
|
||||
Name string `json:"name"`
|
||||
Comment string `json:"comment"`
|
||||
Global bool `json:"global"`
|
||||
}
|
||||
|
||||
func (i *nspFile) UnmarshalJSON(data []byte) error {
|
||||
@@ -483,5 +494,6 @@ func (i *nspFile) UnmarshalJSON(data []byte) error {
|
||||
}
|
||||
i.Name, _ = m["name"].(string)
|
||||
i.Comment, _ = m["comment"].(string)
|
||||
i.Global, _ = m["global"].(bool)
|
||||
return json.Unmarshal(data, &i.Criteria)
|
||||
}
|
||||
|
||||
@@ -107,11 +107,20 @@ var _ = Describe("Playlists", func() {
|
||||
Expect(pls.Rules.Order).To(Equal("desc"))
|
||||
Expect(pls.Rules.Limit).To(Equal(100))
|
||||
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() {
|
||||
_, err := ps.ImportFile(ctx, folder, "invalid_json.nsp")
|
||||
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() {
|
||||
|
||||
@@ -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
|
||||
Rules *criteria.Criteria `structs:"rules" json:"rules"`
|
||||
EvaluatedAt *time.Time `structs:"evaluated_at" json:"evaluatedAt"`
|
||||
Global bool `structs:"global" json:"global"`
|
||||
}
|
||||
|
||||
func (pls Playlist) IsSmartPlaylist() bool {
|
||||
|
||||
@@ -227,9 +227,9 @@ func (r *playlistRepository) refreshSmartPlaylist(pls *model.Playlist) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// Never refresh other users' playlists
|
||||
// Only refresh for owners, unless the playlist is marked as global
|
||||
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)
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/conf/configtest"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"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() {
|
||||
It("fails to Put it in the DB", func() {
|
||||
rules = &criteria.Criteria{
|
||||
|
||||
@@ -200,6 +200,7 @@
|
||||
"duration": "Duração",
|
||||
"ownerName": "Dono",
|
||||
"public": "Pública",
|
||||
"global": "Global",
|
||||
"updatedAt": "Últ. Atualização",
|
||||
"createdAt": "Data de Criação",
|
||||
"songCount": "Músicas",
|
||||
@@ -222,7 +223,8 @@
|
||||
"duplicate_song": "Adicionar músicas duplicadas",
|
||||
"song_exist": "Algumas destas músicas já existem na playlist. Você quer adicionar as duplicadas ou ignorá-las?",
|
||||
"noPlaylistsFound": "Nenhuma playlist encontrada",
|
||||
"noPlaylists": "Nenhuma playlist disponível"
|
||||
"noPlaylists": "Nenhuma playlist disponível",
|
||||
"globalPlaylistPublicDisabled": "Playlists globais são sempre públicas"
|
||||
}
|
||||
},
|
||||
"radio": {
|
||||
|
||||
@@ -35,7 +35,7 @@ func (pub *Router) handleImages(w http.ResponseWriter, r *http.Request) {
|
||||
artId, err := decodeArtworkID(id)
|
||||
if err != nil {
|
||||
log.Error(r, "Error decoding artwork id", "id", id, err)
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
http.Error(w, "invalid request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
size := p.IntOr("size", 0)
|
||||
|
||||
@@ -166,11 +166,30 @@ func getTranscoding(ctx context.Context) (format string, bitRate int) {
|
||||
return
|
||||
}
|
||||
|
||||
func isClientInList(clientList, client string) bool {
|
||||
if clientList == "" || client == "" {
|
||||
return false
|
||||
}
|
||||
clients := strings.Split(clientList, ",")
|
||||
for _, c := range clients {
|
||||
if strings.TrimSpace(c) == client {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func childFromMediaFile(ctx context.Context, mf model.MediaFile) responses.Child {
|
||||
child := responses.Child{}
|
||||
child.Id = mf.ID
|
||||
child.Title = mf.FullTitle()
|
||||
child.IsDir = false
|
||||
|
||||
player, ok := request.PlayerFrom(ctx)
|
||||
if ok && isClientInList(conf.Server.Subsonic.MinimalClients, player.Client) {
|
||||
return child
|
||||
}
|
||||
|
||||
child.Parent = mf.AlbumID
|
||||
child.Album = mf.Album
|
||||
child.Year = int32(mf.Year)
|
||||
@@ -183,7 +202,7 @@ func childFromMediaFile(ctx context.Context, mf model.MediaFile) responses.Child
|
||||
child.BitRate = int32(mf.BitRate)
|
||||
child.CoverArt = mf.CoverArtID().String()
|
||||
child.ContentType = mf.ContentType()
|
||||
player, ok := request.PlayerFrom(ctx)
|
||||
|
||||
if ok && player.ReportRealPath {
|
||||
child.Path = mf.AbsolutePath()
|
||||
} else {
|
||||
@@ -211,8 +230,8 @@ func childFromMediaFile(ctx context.Context, mf model.MediaFile) responses.Child
|
||||
}
|
||||
|
||||
func osChildFromMediaFile(ctx context.Context, mf model.MediaFile) *responses.OpenSubsonicChild {
|
||||
player, _ := request.PlayerFrom(ctx)
|
||||
if strings.Contains(conf.Server.Subsonic.LegacyClients, player.Client) {
|
||||
player, ok := request.PlayerFrom(ctx)
|
||||
if ok && isClientInList(conf.Server.Subsonic.MinimalClients, player.Client) {
|
||||
return nil
|
||||
}
|
||||
child := responses.OpenSubsonicChild{}
|
||||
|
||||
@@ -169,6 +169,190 @@ var _ = Describe("helpers", func() {
|
||||
})
|
||||
})
|
||||
|
||||
DescribeTable("isClientInList",
|
||||
func(list, client string, expected bool) {
|
||||
Expect(isClientInList(list, client)).To(Equal(expected))
|
||||
},
|
||||
Entry("returns false when clientList is empty", "", "some-client", false),
|
||||
Entry("returns false when client is empty", "client1,client2", "", false),
|
||||
Entry("returns false when both are empty", "", "", false),
|
||||
Entry("returns true when client matches single entry", "my-client", "my-client", true),
|
||||
Entry("returns true when client matches first in list", "client1,client2,client3", "client1", true),
|
||||
Entry("returns true when client matches middle in list", "client1,client2,client3", "client2", true),
|
||||
Entry("returns true when client matches last in list", "client1,client2,client3", "client3", true),
|
||||
Entry("returns false when client does not match", "client1,client2", "client3", false),
|
||||
Entry("trims whitespace from client list entries", "client1, client2 , client3", "client2", true),
|
||||
Entry("does not trim the client parameter", "client1,client2", " client1", false),
|
||||
)
|
||||
|
||||
Describe("childFromMediaFile", func() {
|
||||
var mf model.MediaFile
|
||||
var ctx context.Context
|
||||
|
||||
BeforeEach(func() {
|
||||
mf = model.MediaFile{
|
||||
ID: "mf-1",
|
||||
Title: "Test Song",
|
||||
Album: "Test Album",
|
||||
AlbumID: "album-1",
|
||||
Artist: "Test Artist",
|
||||
ArtistID: "artist-1",
|
||||
Year: 2023,
|
||||
Genre: "Rock",
|
||||
TrackNumber: 5,
|
||||
Duration: 180.5,
|
||||
Size: 5000000,
|
||||
Suffix: "mp3",
|
||||
BitRate: 320,
|
||||
}
|
||||
ctx = context.Background()
|
||||
})
|
||||
|
||||
Context("with minimal client", func() {
|
||||
BeforeEach(func() {
|
||||
conf.Server.Subsonic.MinimalClients = "minimal-client"
|
||||
player := model.Player{Client: "minimal-client"}
|
||||
ctx = request.WithPlayer(ctx, player)
|
||||
})
|
||||
|
||||
It("returns only basic fields", func() {
|
||||
child := childFromMediaFile(ctx, mf)
|
||||
Expect(child.Id).To(Equal("mf-1"))
|
||||
Expect(child.Title).To(Equal("Test Song"))
|
||||
Expect(child.IsDir).To(BeFalse())
|
||||
|
||||
// These should not be set
|
||||
Expect(child.Album).To(BeEmpty())
|
||||
Expect(child.Artist).To(BeEmpty())
|
||||
Expect(child.Parent).To(BeEmpty())
|
||||
Expect(child.Year).To(BeZero())
|
||||
Expect(child.Genre).To(BeEmpty())
|
||||
Expect(child.Track).To(BeZero())
|
||||
Expect(child.Duration).To(BeZero())
|
||||
Expect(child.Size).To(BeZero())
|
||||
Expect(child.Suffix).To(BeEmpty())
|
||||
Expect(child.BitRate).To(BeZero())
|
||||
Expect(child.CoverArt).To(BeEmpty())
|
||||
Expect(child.ContentType).To(BeEmpty())
|
||||
Expect(child.Path).To(BeEmpty())
|
||||
})
|
||||
|
||||
It("does not include OpenSubsonic extension", func() {
|
||||
child := childFromMediaFile(ctx, mf)
|
||||
Expect(child.OpenSubsonicChild).To(BeNil())
|
||||
})
|
||||
})
|
||||
|
||||
Context("with non-minimal client", func() {
|
||||
BeforeEach(func() {
|
||||
conf.Server.Subsonic.MinimalClients = "minimal-client"
|
||||
player := model.Player{Client: "regular-client"}
|
||||
ctx = request.WithPlayer(ctx, player)
|
||||
})
|
||||
|
||||
It("returns all fields", func() {
|
||||
child := childFromMediaFile(ctx, mf)
|
||||
Expect(child.Id).To(Equal("mf-1"))
|
||||
Expect(child.Title).To(Equal("Test Song"))
|
||||
Expect(child.IsDir).To(BeFalse())
|
||||
Expect(child.Album).To(Equal("Test Album"))
|
||||
Expect(child.Artist).To(Equal("Test Artist"))
|
||||
Expect(child.Parent).To(Equal("album-1"))
|
||||
Expect(child.Year).To(Equal(int32(2023)))
|
||||
Expect(child.Genre).To(Equal("Rock"))
|
||||
Expect(child.Track).To(Equal(int32(5)))
|
||||
Expect(child.Duration).To(Equal(int32(180)))
|
||||
Expect(child.Size).To(Equal(int64(5000000)))
|
||||
Expect(child.Suffix).To(Equal("mp3"))
|
||||
Expect(child.BitRate).To(Equal(int32(320)))
|
||||
})
|
||||
})
|
||||
|
||||
Context("when minimal clients list is empty", func() {
|
||||
BeforeEach(func() {
|
||||
conf.Server.Subsonic.MinimalClients = ""
|
||||
player := model.Player{Client: "any-client"}
|
||||
ctx = request.WithPlayer(ctx, player)
|
||||
})
|
||||
|
||||
It("returns all fields", func() {
|
||||
child := childFromMediaFile(ctx, mf)
|
||||
Expect(child.Album).To(Equal("Test Album"))
|
||||
Expect(child.Artist).To(Equal("Test Artist"))
|
||||
})
|
||||
})
|
||||
|
||||
Context("when no player in context", func() {
|
||||
It("returns all fields", func() {
|
||||
child := childFromMediaFile(ctx, mf)
|
||||
Expect(child.Album).To(Equal("Test Album"))
|
||||
Expect(child.Artist).To(Equal("Test Artist"))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Describe("osChildFromMediaFile", func() {
|
||||
var mf model.MediaFile
|
||||
var ctx context.Context
|
||||
|
||||
BeforeEach(func() {
|
||||
mf = model.MediaFile{
|
||||
ID: "mf-1",
|
||||
Title: "Test Song",
|
||||
Artist: "Test Artist",
|
||||
Comment: "Test Comment",
|
||||
}
|
||||
ctx = context.Background()
|
||||
})
|
||||
|
||||
Context("with minimal client", func() {
|
||||
BeforeEach(func() {
|
||||
conf.Server.Subsonic.MinimalClients = "minimal-client"
|
||||
player := model.Player{Client: "minimal-client"}
|
||||
ctx = request.WithPlayer(ctx, player)
|
||||
})
|
||||
|
||||
It("returns nil", func() {
|
||||
osChild := osChildFromMediaFile(ctx, mf)
|
||||
Expect(osChild).To(BeNil())
|
||||
})
|
||||
})
|
||||
|
||||
Context("with non-minimal client", func() {
|
||||
BeforeEach(func() {
|
||||
conf.Server.Subsonic.MinimalClients = "minimal-client"
|
||||
player := model.Player{Client: "regular-client"}
|
||||
ctx = request.WithPlayer(ctx, player)
|
||||
})
|
||||
|
||||
It("returns OpenSubsonic child fields", func() {
|
||||
osChild := osChildFromMediaFile(ctx, mf)
|
||||
Expect(osChild).ToNot(BeNil())
|
||||
Expect(osChild.Comment).To(Equal("Test Comment"))
|
||||
})
|
||||
})
|
||||
|
||||
Context("when minimal clients list is empty", func() {
|
||||
BeforeEach(func() {
|
||||
conf.Server.Subsonic.MinimalClients = ""
|
||||
player := model.Player{Client: "any-client"}
|
||||
ctx = request.WithPlayer(ctx, player)
|
||||
})
|
||||
|
||||
It("returns OpenSubsonic child fields", func() {
|
||||
osChild := osChildFromMediaFile(ctx, mf)
|
||||
Expect(osChild).ToNot(BeNil())
|
||||
})
|
||||
})
|
||||
|
||||
Context("when no player in context", func() {
|
||||
It("returns OpenSubsonic child fields", func() {
|
||||
osChild := osChildFromMediaFile(ctx, mf)
|
||||
Expect(osChild).ToNot(BeNil())
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Describe("selectedMusicFolderIds", func() {
|
||||
var user model.User
|
||||
var ctx context.Context
|
||||
|
||||
@@ -7,8 +7,10 @@ import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/request"
|
||||
"github.com/navidrome/navidrome/server/subsonic/responses"
|
||||
"github.com/navidrome/navidrome/utils/req"
|
||||
"github.com/navidrome/navidrome/utils/slice"
|
||||
@@ -23,7 +25,7 @@ func (api *Router) GetPlaylists(r *http.Request) (*responses.Subsonic, error) {
|
||||
}
|
||||
response := newResponse()
|
||||
response.Playlists = &responses.Playlists{
|
||||
Playlist: slice.Map(allPls, api.buildPlaylist),
|
||||
Playlist: slice.MapWithArg(allPls, ctx, api.buildPlaylist),
|
||||
}
|
||||
return response, nil
|
||||
}
|
||||
@@ -51,7 +53,7 @@ func (api *Router) getPlaylist(ctx context.Context, id string) (*responses.Subso
|
||||
|
||||
response := newResponse()
|
||||
response.Playlist = &responses.PlaylistWithSongs{
|
||||
Playlist: api.buildPlaylist(*pls),
|
||||
Playlist: api.buildPlaylist(ctx, *pls),
|
||||
}
|
||||
response.Playlist.Entry = slice.MapWithArg(pls.MediaFiles(), ctx, childFromMediaFile)
|
||||
return response, nil
|
||||
@@ -152,21 +154,28 @@ func (api *Router) UpdatePlaylist(r *http.Request) (*responses.Subsonic, error)
|
||||
return newResponse(), nil
|
||||
}
|
||||
|
||||
func (api *Router) buildPlaylist(p model.Playlist) responses.Playlist {
|
||||
func (api *Router) buildPlaylist(ctx context.Context, p model.Playlist) responses.Playlist {
|
||||
pls := responses.Playlist{}
|
||||
pls.Id = p.ID
|
||||
pls.Name = p.Name
|
||||
pls.Comment = p.Comment
|
||||
pls.SongCount = int32(p.SongCount)
|
||||
pls.Owner = p.OwnerName
|
||||
pls.Duration = int32(p.Duration)
|
||||
pls.Public = p.Public
|
||||
pls.Created = p.CreatedAt
|
||||
pls.CoverArt = p.CoverArtID().String()
|
||||
if p.IsSmartPlaylist() {
|
||||
pls.Changed = time.Now()
|
||||
} else {
|
||||
pls.Changed = p.UpdatedAt
|
||||
}
|
||||
|
||||
player, ok := request.PlayerFrom(ctx)
|
||||
if ok && isClientInList(conf.Server.Subsonic.MinimalClients, player.Client) {
|
||||
return pls
|
||||
}
|
||||
|
||||
pls.Comment = p.Comment
|
||||
pls.Owner = p.OwnerName
|
||||
pls.Public = p.Public
|
||||
pls.CoverArt = p.CoverArtID().String()
|
||||
|
||||
return pls
|
||||
}
|
||||
|
||||
@@ -2,9 +2,12 @@ package subsonic
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/core"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/request"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
@@ -12,6 +15,108 @@ import (
|
||||
|
||||
var _ core.Playlists = (*fakePlaylists)(nil)
|
||||
|
||||
var _ = Describe("buildPlaylist", func() {
|
||||
var router *Router
|
||||
var ds model.DataStore
|
||||
var ctx context.Context
|
||||
var playlist model.Playlist
|
||||
|
||||
BeforeEach(func() {
|
||||
ds = &tests.MockDataStore{}
|
||||
router = New(ds, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
|
||||
ctx = context.Background()
|
||||
|
||||
createdAt := time.Date(2023, 1, 15, 10, 30, 0, 0, time.UTC)
|
||||
updatedAt := time.Date(2023, 2, 20, 14, 45, 0, 0, time.UTC)
|
||||
|
||||
playlist = model.Playlist{
|
||||
ID: "pls-1",
|
||||
Name: "My Playlist",
|
||||
Comment: "Test comment",
|
||||
OwnerName: "admin",
|
||||
Public: true,
|
||||
SongCount: 10,
|
||||
Duration: 600,
|
||||
CreatedAt: createdAt,
|
||||
UpdatedAt: updatedAt,
|
||||
}
|
||||
})
|
||||
|
||||
Context("with minimal client", func() {
|
||||
BeforeEach(func() {
|
||||
conf.Server.Subsonic.MinimalClients = "minimal-client"
|
||||
player := model.Player{Client: "minimal-client"}
|
||||
ctx = request.WithPlayer(ctx, player)
|
||||
})
|
||||
|
||||
It("returns only basic fields", func() {
|
||||
result := router.buildPlaylist(ctx, playlist)
|
||||
|
||||
Expect(result.Id).To(Equal("pls-1"))
|
||||
Expect(result.Name).To(Equal("My Playlist"))
|
||||
Expect(result.SongCount).To(Equal(int32(10)))
|
||||
Expect(result.Duration).To(Equal(int32(600)))
|
||||
Expect(result.Created).To(Equal(playlist.CreatedAt))
|
||||
Expect(result.Changed).To(Equal(playlist.UpdatedAt))
|
||||
|
||||
// These should not be set
|
||||
Expect(result.Comment).To(BeEmpty())
|
||||
Expect(result.Owner).To(BeEmpty())
|
||||
Expect(result.Public).To(BeFalse())
|
||||
Expect(result.CoverArt).To(BeEmpty())
|
||||
})
|
||||
})
|
||||
|
||||
Context("with non-minimal client", func() {
|
||||
BeforeEach(func() {
|
||||
conf.Server.Subsonic.MinimalClients = "minimal-client"
|
||||
player := model.Player{Client: "regular-client"}
|
||||
ctx = request.WithPlayer(ctx, player)
|
||||
})
|
||||
|
||||
It("returns all fields", func() {
|
||||
result := router.buildPlaylist(ctx, playlist)
|
||||
|
||||
Expect(result.Id).To(Equal("pls-1"))
|
||||
Expect(result.Name).To(Equal("My Playlist"))
|
||||
Expect(result.SongCount).To(Equal(int32(10)))
|
||||
Expect(result.Duration).To(Equal(int32(600)))
|
||||
Expect(result.Created).To(Equal(playlist.CreatedAt))
|
||||
Expect(result.Changed).To(Equal(playlist.UpdatedAt))
|
||||
Expect(result.Comment).To(Equal("Test comment"))
|
||||
Expect(result.Owner).To(Equal("admin"))
|
||||
Expect(result.Public).To(BeTrue())
|
||||
})
|
||||
})
|
||||
|
||||
Context("when minimal clients list is empty", func() {
|
||||
BeforeEach(func() {
|
||||
conf.Server.Subsonic.MinimalClients = ""
|
||||
player := model.Player{Client: "any-client"}
|
||||
ctx = request.WithPlayer(ctx, player)
|
||||
})
|
||||
|
||||
It("returns all fields", func() {
|
||||
result := router.buildPlaylist(ctx, playlist)
|
||||
|
||||
Expect(result.Comment).To(Equal("Test comment"))
|
||||
Expect(result.Owner).To(Equal("admin"))
|
||||
Expect(result.Public).To(BeTrue())
|
||||
})
|
||||
})
|
||||
|
||||
Context("when no player in context", func() {
|
||||
It("returns all fields", func() {
|
||||
result := router.buildPlaylist(ctx, playlist)
|
||||
|
||||
Expect(result.Comment).To(Equal("Test comment"))
|
||||
Expect(result.Owner).To(Equal("admin"))
|
||||
Expect(result.Public).To(BeTrue())
|
||||
})
|
||||
})
|
||||
|
||||
})
|
||||
|
||||
var _ = Describe("UpdatePlaylist", func() {
|
||||
var router *Router
|
||||
var ds model.DataStore
|
||||
|
||||
@@ -9,7 +9,6 @@
|
||||
{
|
||||
"id": "1",
|
||||
"isDir": false,
|
||||
"isVideo": false,
|
||||
"bpm": 0,
|
||||
"comment": "",
|
||||
"sortName": "sort name",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.16.1" type="navidrome" serverVersion="v0.55.0" openSubsonic="true">
|
||||
<albumList>
|
||||
<album id="1" isDir="false" isVideo="false" sortName="sort name" mediaType="album" musicBrainzId="00000000-0000-0000-0000-000000000000" displayArtist="Display artist" displayAlbumArtist="Display album artist" explicitStatus="explicit">
|
||||
<album id="1" isDir="false" sortName="sort name" mediaType="album" musicBrainzId="00000000-0000-0000-0000-000000000000" displayArtist="Display artist" displayAlbumArtist="Display album artist" explicitStatus="explicit">
|
||||
<genres name="Genre 1"></genres>
|
||||
<genres name="Genre 2"></genres>
|
||||
<moods>mood1</moods>
|
||||
|
||||
@@ -9,8 +9,7 @@
|
||||
{
|
||||
"id": "1",
|
||||
"isDir": false,
|
||||
"title": "title",
|
||||
"isVideo": false
|
||||
"title": "title"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.16.1" type="navidrome" serverVersion="v0.55.0" openSubsonic="true">
|
||||
<albumList>
|
||||
<album id="1" isDir="false" title="title" isVideo="false"></album>
|
||||
<album id="1" isDir="false" title="title"></album>
|
||||
</albumList>
|
||||
</subsonic-response>
|
||||
|
||||
@@ -93,7 +93,6 @@
|
||||
"transcodedSuffix": "mp3",
|
||||
"duration": 146,
|
||||
"bitRate": 320,
|
||||
"isVideo": false,
|
||||
"bpm": 127,
|
||||
"comment": "a comment",
|
||||
"sortName": "sorted song",
|
||||
@@ -185,7 +184,6 @@
|
||||
"transcodedSuffix": "mp3",
|
||||
"duration": 146,
|
||||
"bitRate": 320,
|
||||
"isVideo": false,
|
||||
"bpm": 0,
|
||||
"comment": "",
|
||||
"sortName": "",
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
<moods>sad</moods>
|
||||
<artists id="1" name="artist1"></artists>
|
||||
<artists id="2" name="artist2"></artists>
|
||||
<song id="1" isDir="true" title="title" album="album" artist="artist" track="1" year="1985" genre="Rock" coverArt="1" size="8421341" contentType="audio/flac" suffix="flac" starred="2016-03-02T20:30:00Z" transcodedContentType="audio/mpeg" transcodedSuffix="mp3" duration="146" bitRate="320" isVideo="false" bpm="127" comment="a comment" sortName="sorted song" mediaType="song" musicBrainzId="4321" channelCount="2" samplingRate="44100" bitDepth="16" displayArtist="artist1 & artist2" displayAlbumArtist="album artist1 & album artist2" displayComposer="composer 1 & composer 2" explicitStatus="clean">
|
||||
<song id="1" isDir="true" title="title" album="album" artist="artist" track="1" year="1985" genre="Rock" coverArt="1" size="8421341" contentType="audio/flac" suffix="flac" starred="2016-03-02T20:30:00Z" transcodedContentType="audio/mpeg" transcodedSuffix="mp3" duration="146" bitRate="320" bpm="127" comment="a comment" sortName="sorted song" mediaType="song" musicBrainzId="4321" channelCount="2" samplingRate="44100" bitDepth="16" displayArtist="artist1 & artist2" displayAlbumArtist="album artist1 & album artist2" displayComposer="composer 1 & composer 2" explicitStatus="clean">
|
||||
<isrc>ISRC-1</isrc>
|
||||
<genres name="rock"></genres>
|
||||
<genres name="progressive"></genres>
|
||||
@@ -33,7 +33,7 @@
|
||||
<artist id="2" name="artist2"></artist>
|
||||
</contributors>
|
||||
</song>
|
||||
<song id="2" isDir="true" title="title" album="album" artist="artist" track="1" year="1985" genre="Rock" coverArt="1" size="8421341" contentType="audio/flac" suffix="flac" starred="2016-03-02T20:30:00Z" transcodedContentType="audio/mpeg" transcodedSuffix="mp3" duration="146" bitRate="320" isVideo="false">
|
||||
<song id="2" isDir="true" title="title" album="album" artist="artist" track="1" year="1985" genre="Rock" coverArt="1" size="8421341" contentType="audio/flac" suffix="flac" starred="2016-03-02T20:30:00Z" transcodedContentType="audio/mpeg" transcodedSuffix="mp3" duration="146" bitRate="320">
|
||||
<replayGain trackGain="0" albumGain="0" trackPeak="0" albumPeak="0" baseGain="0" fallbackGain="0"></replayGain>
|
||||
</song>
|
||||
</album>
|
||||
|
||||
@@ -10,8 +10,7 @@
|
||||
"entry": {
|
||||
"id": "1",
|
||||
"isDir": false,
|
||||
"title": "title",
|
||||
"isVideo": false
|
||||
"title": "title"
|
||||
},
|
||||
"position": 123,
|
||||
"username": "user2",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.16.1" type="navidrome" serverVersion="v0.55.0" openSubsonic="true">
|
||||
<bookmarks>
|
||||
<bookmark position="123" username="user2" comment="a comment" created="0001-01-01T00:00:00Z" changed="0001-01-01T00:00:00Z">
|
||||
<entry id="1" isDir="false" title="title" isVideo="false"></entry>
|
||||
<entry id="1" isDir="false" title="title"></entry>
|
||||
</bookmark>
|
||||
</bookmarks>
|
||||
</subsonic-response>
|
||||
|
||||
@@ -24,7 +24,6 @@
|
||||
"transcodedSuffix": "mp3",
|
||||
"duration": 146,
|
||||
"bitRate": 320,
|
||||
"isVideo": false,
|
||||
"bpm": 127,
|
||||
"comment": "a comment",
|
||||
"sortName": "sorted title",
|
||||
@@ -116,7 +115,6 @@
|
||||
{
|
||||
"id": "",
|
||||
"isDir": false,
|
||||
"isVideo": false,
|
||||
"bpm": 0,
|
||||
"comment": "",
|
||||
"sortName": "",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.16.1" type="navidrome" serverVersion="v0.55.0" openSubsonic="true">
|
||||
<directory id="1" name="N">
|
||||
<child id="1" isDir="true" title="title" album="album" artist="artist" track="1" year="1985" genre="Rock" coverArt="1" size="8421341" contentType="audio/flac" suffix="flac" starred="2016-03-02T20:30:00Z" transcodedContentType="audio/mpeg" transcodedSuffix="mp3" duration="146" bitRate="320" isVideo="false" bpm="127" comment="a comment" sortName="sorted title" mediaType="song" musicBrainzId="4321" channelCount="2" samplingRate="44100" bitDepth="16" displayArtist="artist 1 & artist 2" displayAlbumArtist="album artist 1 & album artist 2" displayComposer="composer 1 & composer 2" explicitStatus="clean">
|
||||
<child id="1" isDir="true" title="title" album="album" artist="artist" track="1" year="1985" genre="Rock" coverArt="1" size="8421341" contentType="audio/flac" suffix="flac" starred="2016-03-02T20:30:00Z" transcodedContentType="audio/mpeg" transcodedSuffix="mp3" duration="146" bitRate="320" bpm="127" comment="a comment" sortName="sorted title" mediaType="song" musicBrainzId="4321" channelCount="2" samplingRate="44100" bitDepth="16" displayArtist="artist 1 & artist 2" displayAlbumArtist="album artist 1 & album artist 2" displayComposer="composer 1 & composer 2" explicitStatus="clean">
|
||||
<isrc>ISRC-1</isrc>
|
||||
<isrc>ISRC-2</isrc>
|
||||
<genres name="rock"></genres>
|
||||
@@ -25,7 +25,7 @@
|
||||
<artist id="4" name="composer2"></artist>
|
||||
</contributors>
|
||||
</child>
|
||||
<child id="" isDir="false" isVideo="false">
|
||||
<child id="" isDir="false">
|
||||
<replayGain trackGain="0" albumGain="0" trackPeak="0" albumPeak="0" baseGain="0" fallbackGain="0"></replayGain>
|
||||
</child>
|
||||
</directory>
|
||||
|
||||
@@ -8,8 +8,7 @@
|
||||
"child": [
|
||||
{
|
||||
"id": "1",
|
||||
"isDir": false,
|
||||
"isVideo": false
|
||||
"isDir": false
|
||||
}
|
||||
],
|
||||
"id": "",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.16.1" type="navidrome" serverVersion="v0.55.0" openSubsonic="true">
|
||||
<directory id="" name="">
|
||||
<child id="1" isDir="false" isVideo="false"></child>
|
||||
<child id="1" isDir="false"></child>
|
||||
</directory>
|
||||
</subsonic-response>
|
||||
|
||||
@@ -9,7 +9,6 @@
|
||||
{
|
||||
"id": "1",
|
||||
"isDir": false,
|
||||
"isVideo": false,
|
||||
"bpm": 0,
|
||||
"comment": "",
|
||||
"sortName": "",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.16.1" type="navidrome" serverVersion="v0.55.0" openSubsonic="true">
|
||||
<directory id="" name="">
|
||||
<child id="1" isDir="false" isVideo="false"></child>
|
||||
<child id="1" isDir="false"></child>
|
||||
</directory>
|
||||
</subsonic-response>
|
||||
|
||||
@@ -9,8 +9,7 @@
|
||||
{
|
||||
"id": "1",
|
||||
"isDir": false,
|
||||
"title": "title",
|
||||
"isVideo": false
|
||||
"title": "title"
|
||||
}
|
||||
],
|
||||
"id": "1",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.16.1" type="navidrome" serverVersion="v0.55.0" openSubsonic="true">
|
||||
<directory id="1" name="N">
|
||||
<child id="1" isDir="false" title="title" isVideo="false"></child>
|
||||
<child id="1" isDir="false" title="title"></child>
|
||||
</directory>
|
||||
</subsonic-response>
|
||||
|
||||
@@ -9,8 +9,7 @@
|
||||
{
|
||||
"id": "1",
|
||||
"isDir": false,
|
||||
"title": "title",
|
||||
"isVideo": false
|
||||
"title": "title"
|
||||
}
|
||||
],
|
||||
"current": "111",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.16.1" type="navidrome" serverVersion="v0.55.0" openSubsonic="true">
|
||||
<playQueue current="111" position="243" username="user1" changed="0001-01-01T00:00:00Z" changedBy="a_client">
|
||||
<entry id="1" isDir="false" title="title" isVideo="false"></entry>
|
||||
<entry id="1" isDir="false" title="title"></entry>
|
||||
</playQueue>
|
||||
</subsonic-response>
|
||||
|
||||
@@ -9,8 +9,7 @@
|
||||
{
|
||||
"id": "1",
|
||||
"isDir": false,
|
||||
"title": "title",
|
||||
"isVideo": false
|
||||
"title": "title"
|
||||
}
|
||||
],
|
||||
"currentIndex": 0,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.16.1" type="navidrome" serverVersion="v0.55.0" openSubsonic="true">
|
||||
<playQueueByIndex currentIndex="0" position="243" username="user1" changed="0001-01-01T00:00:00Z" changedBy="a_client">
|
||||
<entry id="1" isDir="false" title="title" isVideo="false"></entry>
|
||||
<entry id="1" isDir="false" title="title"></entry>
|
||||
</playQueueByIndex>
|
||||
</subsonic-response>
|
||||
|
||||
@@ -23,7 +23,6 @@
|
||||
"name": "bbb",
|
||||
"songCount": 0,
|
||||
"duration": 0,
|
||||
"public": false,
|
||||
"created": "0001-01-01T00:00:00Z",
|
||||
"changed": "0001-01-01T00:00:00Z"
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.16.1" type="navidrome" serverVersion="v0.55.0" openSubsonic="true">
|
||||
<playlists>
|
||||
<playlist id="111" name="aaa" comment="comment" songCount="2" duration="120" public="true" owner="admin" created="0001-01-01T00:00:00Z" changed="0001-01-01T00:00:00Z" coverArt="pl-123123123123"></playlist>
|
||||
<playlist id="222" name="bbb" songCount="0" duration="0" public="false" created="0001-01-01T00:00:00Z" changed="0001-01-01T00:00:00Z"></playlist>
|
||||
<playlist id="222" name="bbb" songCount="0" duration="0" created="0001-01-01T00:00:00Z" changed="0001-01-01T00:00:00Z"></playlist>
|
||||
</playlists>
|
||||
</subsonic-response>
|
||||
|
||||
@@ -14,8 +14,7 @@
|
||||
"title": "title",
|
||||
"album": "album",
|
||||
"artist": "artist",
|
||||
"duration": 120,
|
||||
"isVideo": false
|
||||
"duration": 120
|
||||
},
|
||||
{
|
||||
"id": "2",
|
||||
@@ -23,8 +22,7 @@
|
||||
"title": "title 2",
|
||||
"album": "album",
|
||||
"artist": "artist",
|
||||
"duration": 300,
|
||||
"isVideo": false
|
||||
"duration": 300
|
||||
}
|
||||
],
|
||||
"id": "ABC123",
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.16.1" type="navidrome" serverVersion="v0.55.0" openSubsonic="true">
|
||||
<shares>
|
||||
<share id="ABC123" url="http://localhost/p/ABC123" description="Check it out!" username="deluan" created="2016-03-02T20:30:00Z" expires="2016-03-02T20:30:00Z" lastVisited="2016-03-02T20:30:00Z" visitCount="2">
|
||||
<entry id="1" isDir="false" title="title" album="album" artist="artist" duration="120" isVideo="false"></entry>
|
||||
<entry id="2" isDir="false" title="title 2" album="album" artist="artist" duration="300" isVideo="false"></entry>
|
||||
<entry id="1" isDir="false" title="title" album="album" artist="artist" duration="120"></entry>
|
||||
<entry id="2" isDir="false" title="title 2" album="album" artist="artist" duration="300"></entry>
|
||||
</share>
|
||||
</shares>
|
||||
</subsonic-response>
|
||||
|
||||
@@ -9,8 +9,7 @@
|
||||
{
|
||||
"id": "1",
|
||||
"isDir": false,
|
||||
"title": "title",
|
||||
"isVideo": false
|
||||
"title": "title"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.16.1" type="navidrome" serverVersion="v0.55.0" openSubsonic="true">
|
||||
<similarSongs>
|
||||
<song id="1" isDir="false" title="title" isVideo="false"></song>
|
||||
<song id="1" isDir="false" title="title"></song>
|
||||
</similarSongs>
|
||||
</subsonic-response>
|
||||
|
||||
@@ -9,8 +9,7 @@
|
||||
{
|
||||
"id": "1",
|
||||
"isDir": false,
|
||||
"title": "title",
|
||||
"isVideo": false
|
||||
"title": "title"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.16.1" type="navidrome" serverVersion="v0.55.0" openSubsonic="true">
|
||||
<similarSongs2>
|
||||
<song id="1" isDir="false" title="title" isVideo="false"></song>
|
||||
<song id="1" isDir="false" title="title"></song>
|
||||
</similarSongs2>
|
||||
</subsonic-response>
|
||||
|
||||
@@ -9,8 +9,7 @@
|
||||
{
|
||||
"id": "1",
|
||||
"isDir": false,
|
||||
"title": "title",
|
||||
"isVideo": false
|
||||
"title": "title"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.16.1" type="navidrome" serverVersion="v0.55.0" openSubsonic="true">
|
||||
<topSongs>
|
||||
<song id="1" isDir="false" title="title" isVideo="false"></song>
|
||||
<song id="1" isDir="false" title="title"></song>
|
||||
</topSongs>
|
||||
</subsonic-response>
|
||||
|
||||
@@ -161,7 +161,7 @@ type Child struct {
|
||||
Type string `xml:"type,attr,omitempty" json:"type,omitempty"`
|
||||
UserRating int32 `xml:"userRating,attr,omitempty" json:"userRating,omitempty"`
|
||||
SongCount int32 `xml:"songCount,attr,omitempty" json:"songCount,omitempty"`
|
||||
IsVideo bool `xml:"isVideo,attr" json:"isVideo"`
|
||||
IsVideo bool `xml:"isVideo,attr,omitempty" json:"isVideo,omitempty"`
|
||||
BookmarkPosition int64 `xml:"bookmarkPosition,attr,omitempty" json:"bookmarkPosition,omitempty"`
|
||||
/*
|
||||
<xs:attribute name="averageRating" type="sub:AverageRating" use="optional"/> <!-- Added in 1.6.0 -->
|
||||
@@ -177,7 +177,7 @@ type OpenSubsonicChild struct {
|
||||
SortName string `xml:"sortName,attr,omitempty" json:"sortName"`
|
||||
MediaType MediaType `xml:"mediaType,attr,omitempty" json:"mediaType"`
|
||||
MusicBrainzId string `xml:"musicBrainzId,attr,omitempty" json:"musicBrainzId"`
|
||||
Isrc Array[string] `xml:"isrc,omitempty" json:"isrc"`
|
||||
Isrc Array[string] `xml:"isrc,omitempty" json:"isrc"`
|
||||
Genres Array[ItemGenre] `xml:"genres,omitempty" json:"genres"`
|
||||
ReplayGain ReplayGain `xml:"replayGain,omitempty" json:"replayGain"`
|
||||
ChannelCount int32 `xml:"channelCount,attr,omitempty" json:"channelCount"`
|
||||
@@ -308,7 +308,7 @@ type Playlist struct {
|
||||
Comment string `xml:"comment,attr,omitempty" json:"comment,omitempty"`
|
||||
SongCount int32 `xml:"songCount,attr" json:"songCount"`
|
||||
Duration int32 `xml:"duration,attr" json:"duration"`
|
||||
Public bool `xml:"public,attr" json:"public"`
|
||||
Public bool `xml:"public,attr,omitempty" json:"public,omitempty"`
|
||||
Owner string `xml:"owner,attr,omitempty" json:"owner,omitempty"`
|
||||
Created time.Time `xml:"created,attr" json:"created"`
|
||||
Changed time.Time `xml:"changed,attr" json:"changed"`
|
||||
|
||||
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"
|
||||
}
|
||||
@@ -9,7 +9,9 @@ export const isReadOnly = (ownerId) => {
|
||||
return !isWritable(ownerId)
|
||||
}
|
||||
|
||||
export const isSmartPlaylist = (pls) => !!pls.rules
|
||||
export const isSmartPlaylist = (pls) => !!pls?.rules
|
||||
|
||||
export const isGlobalPlaylist = (pls) => isSmartPlaylist(pls) && !!pls?.global
|
||||
|
||||
export const canChangeTracks = (pls) =>
|
||||
isWritable(pls.ownerId) && !isSmartPlaylist(pls)
|
||||
isWritable(pls?.ownerId) && !isSmartPlaylist(pls)
|
||||
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
isWritable,
|
||||
isReadOnly,
|
||||
isSmartPlaylist,
|
||||
isGlobalPlaylist,
|
||||
canChangeTracks,
|
||||
} from './playlistUtils'
|
||||
|
||||
@@ -56,6 +57,28 @@ describe('playlistUtils', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('isGlobalPlaylist', () => {
|
||||
it('returns true if playlist is smart and global', () => {
|
||||
const playlist = { rules: [], global: true }
|
||||
expect(isGlobalPlaylist(playlist)).toBe(true)
|
||||
})
|
||||
|
||||
it('returns false if playlist is smart but not global', () => {
|
||||
const playlist = { rules: [], global: false }
|
||||
expect(isGlobalPlaylist(playlist)).toBe(false)
|
||||
})
|
||||
|
||||
it('returns false if playlist is not smart even if global is true', () => {
|
||||
const playlist = { global: true }
|
||||
expect(isGlobalPlaylist(playlist)).toBe(false)
|
||||
})
|
||||
|
||||
it('returns false if playlist is not smart and not global', () => {
|
||||
const playlist = {}
|
||||
expect(isGlobalPlaylist(playlist)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('canChangeTracks', () => {
|
||||
it('returns true if user is the owner and playlist is not smart', () => {
|
||||
localStorage.setItem('userId', 'user1')
|
||||
|
||||
@@ -200,6 +200,7 @@
|
||||
"duration": "Duration",
|
||||
"ownerName": "Owner",
|
||||
"public": "Public",
|
||||
"global": "Global",
|
||||
"updatedAt": "Updated at",
|
||||
"createdAt": "Created at",
|
||||
"songCount": "Songs",
|
||||
@@ -222,7 +223,8 @@
|
||||
"duplicate_song": "Add duplicated songs",
|
||||
"song_exist": "There are duplicates being added to the playlist. Would you like to add the duplicates or skip them?",
|
||||
"noPlaylistsFound": "No playlists found",
|
||||
"noPlaylists": "No playlists available"
|
||||
"noPlaylists": "No playlists available",
|
||||
"globalPlaylistPublicDisabled": "Global playlists are always public"
|
||||
}
|
||||
},
|
||||
"radio": {
|
||||
|
||||
@@ -2,15 +2,22 @@ import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardMedia,
|
||||
Chip,
|
||||
Typography,
|
||||
useMediaQuery,
|
||||
} from '@material-ui/core'
|
||||
import PublicIcon from '@material-ui/icons/Public'
|
||||
import { makeStyles } from '@material-ui/core/styles'
|
||||
import { useTranslate } from 'react-admin'
|
||||
import { useCallback, useState, useEffect } from 'react'
|
||||
import Lightbox from 'react-image-lightbox'
|
||||
import 'react-image-lightbox/style.css'
|
||||
import { CollapsibleComment, DurationField, SizeField } from '../common'
|
||||
import {
|
||||
CollapsibleComment,
|
||||
DurationField,
|
||||
SizeField,
|
||||
isGlobalPlaylist,
|
||||
} from '../common'
|
||||
import subsonic from '../subsonic'
|
||||
|
||||
const useStyles = makeStyles(
|
||||
@@ -77,6 +84,10 @@ const useStyles = makeStyles(
|
||||
marginTop: '1em',
|
||||
marginBottom: '0.5em',
|
||||
},
|
||||
globalChip: {
|
||||
marginLeft: '0.5em',
|
||||
verticalAlign: 'middle',
|
||||
},
|
||||
}),
|
||||
{
|
||||
name: 'NDPlaylistDetails',
|
||||
@@ -146,6 +157,14 @@ const PlaylistDetails = (props) => {
|
||||
className={classes.title}
|
||||
>
|
||||
{record.name || translate('ra.page.loading')}
|
||||
{isGlobalPlaylist(record) && (
|
||||
<Chip
|
||||
icon={<PublicIcon />}
|
||||
label={translate('resources.playlist.fields.global')}
|
||||
size="small"
|
||||
className={classes.globalChip}
|
||||
/>
|
||||
)}
|
||||
</Typography>
|
||||
<Typography component="p" className={classes.stats}>
|
||||
{record.songCount ? (
|
||||
|
||||
@@ -11,7 +11,16 @@ import {
|
||||
ReferenceInput,
|
||||
SelectInput,
|
||||
} from 'react-admin'
|
||||
import { isWritable, Title } from '../common'
|
||||
import { useForm } from 'react-final-form'
|
||||
import Tooltip from '@material-ui/core/Tooltip'
|
||||
import { makeStyles } from '@material-ui/core/styles'
|
||||
import { isWritable, isSmartPlaylist, Title } from '../common'
|
||||
|
||||
const useStyles = makeStyles({
|
||||
tooltipWrapper: {
|
||||
display: 'inline-block',
|
||||
},
|
||||
})
|
||||
|
||||
const SyncFragment = ({ formData, variant, ...rest }) => {
|
||||
return (
|
||||
@@ -28,9 +37,48 @@ const PlaylistTitle = ({ record }) => {
|
||||
return <Title subTitle={`${resourceName} "${record ? record.name : ''}"`} />
|
||||
}
|
||||
|
||||
const PublicInput = ({ record, formData }) => {
|
||||
const translate = useTranslate()
|
||||
const classes = useStyles()
|
||||
const isGlobal = isSmartPlaylist(record) && formData?.global
|
||||
const disabled = !isWritable(record.ownerId) || isGlobal
|
||||
|
||||
const input = <BooleanInput source="public" disabled={disabled} />
|
||||
|
||||
if (isGlobal) {
|
||||
return (
|
||||
<Tooltip
|
||||
title={translate(
|
||||
'resources.playlist.message.globalPlaylistPublicDisabled',
|
||||
)}
|
||||
>
|
||||
<div className={classes.tooltipWrapper}>{input}</div>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
return input
|
||||
}
|
||||
|
||||
const GlobalInput = ({ record }) => {
|
||||
const form = useForm()
|
||||
const handleChange = (value) => {
|
||||
if (value) {
|
||||
form.change('public', true)
|
||||
}
|
||||
}
|
||||
return (
|
||||
<BooleanInput
|
||||
source="global"
|
||||
disabled={!isWritable(record.ownerId)}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const PlaylistEditForm = (props) => {
|
||||
const { record } = props
|
||||
const { permissions } = usePermissions()
|
||||
const isSmart = isSmartPlaylist(record)
|
||||
return (
|
||||
<SimpleForm redirect="list" variant={'outlined'} {...props}>
|
||||
<TextInput source="name" validate={required()} />
|
||||
@@ -50,7 +98,10 @@ const PlaylistEditForm = (props) => {
|
||||
) : (
|
||||
<TextField source="ownerName" />
|
||||
)}
|
||||
<BooleanInput source="public" disabled={!isWritable(record.ownerId)} />
|
||||
<FormDataConsumer>
|
||||
{({ formData }) => <PublicInput record={record} formData={formData} />}
|
||||
</FormDataConsumer>
|
||||
{isSmart && <GlobalInput record={record} />}
|
||||
<FormDataConsumer>
|
||||
{(formDataProps) => <SyncFragment {...formDataProps} />}
|
||||
</FormDataConsumer>
|
||||
|
||||
@@ -14,8 +14,10 @@ import {
|
||||
useRecordContext,
|
||||
BulkDeleteButton,
|
||||
usePermissions,
|
||||
useTranslate,
|
||||
} from 'react-admin'
|
||||
import Switch from '@material-ui/core/Switch'
|
||||
import Tooltip from '@material-ui/core/Tooltip'
|
||||
import { makeStyles } from '@material-ui/core/styles'
|
||||
import { useMediaQuery } from '@material-ui/core'
|
||||
import {
|
||||
@@ -23,6 +25,7 @@ import {
|
||||
List,
|
||||
Writable,
|
||||
isWritable,
|
||||
isGlobalPlaylist,
|
||||
useSelectedFields,
|
||||
useResourceRefresh,
|
||||
} from '../common'
|
||||
@@ -59,6 +62,7 @@ const PlaylistFilter = (props) => {
|
||||
const TogglePublicInput = ({ resource, source }) => {
|
||||
const record = useRecordContext()
|
||||
const notify = useNotify()
|
||||
const translate = useTranslate()
|
||||
const [togglePublic] = useUpdate(
|
||||
resource,
|
||||
record.id,
|
||||
@@ -79,13 +83,29 @@ const TogglePublicInput = ({ resource, source }) => {
|
||||
e.stopPropagation()
|
||||
}
|
||||
|
||||
return (
|
||||
const isGlobal = isGlobalPlaylist(record)
|
||||
const disabled = !isWritable(record.ownerId) || isGlobal
|
||||
|
||||
const switchElement = (
|
||||
<Switch
|
||||
checked={record[source]}
|
||||
onClick={handleClick}
|
||||
disabled={!isWritable(record.ownerId)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
)
|
||||
|
||||
if (isGlobal) {
|
||||
return (
|
||||
<Tooltip
|
||||
title={translate(
|
||||
'resources.playlist.message.globalPlaylistPublicDisabled',
|
||||
)}
|
||||
>
|
||||
<span>{switchElement}</span>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
return switchElement
|
||||
}
|
||||
|
||||
const ToggleAutoImport = ({ resource, source }) => {
|
||||
|
||||
Reference in New Issue
Block a user