Compare commits

...

6 Commits

Author SHA1 Message Date
Deluan Quintão
9465af18e4 Update persistence/playlist_repository.go
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-01-16 11:43:39 -05:00
Deluan Quintão
f85c1beedb Update ui/src/common/playlistUtils.js
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
2026-01-16 11:35:49 -05:00
Deluan
c7b93805ce feat: implement global playlist functionality with UI updates
Signed-off-by: Deluan <deluan@navidrome.org>
2026-01-16 11:24:58 -05:00
Deluan
8861eebe21 feat: add global smart playlist functionality
Signed-off-by: Deluan <deluan@navidrome.org>
2026-01-16 10:27:51 -05:00
Alex Gustafsson
13be8e6dfb fix: don't expose JWT-related errors (#4892)
The share / public router would expose the parse error of JWTs when
serving images, leading to unnecesasry information disclosure.

Replace any error with a generic "invalid request" as is already done
when serving the streams themselves.
2026-01-16 06:20:10 -05:00
Matthew Simpson
9ab0c2dc67 feat: new "Subsonic Minimal Clients" configuration option (#4850)
* Add `.editorconfig` file

Hints to users how to properly indent Go files (my setup was defaulting
to 2 spaces).

* Add Subsonic API minimal config option

This will allow users to specify clients which can operate with or need
the minimum required fields as per the [SubSonic API
spec](https://subsonic.org/pages/api.jsp).

* Return only required fields for Child Objects

For a minimal client, only return the required fields for Child Objects.

* Return only required fields for Playlist objects

* refactor: simplify client list checks and improve playlist response handling

Signed-off-by: Deluan <deluan@navidrome.org>

* test: add unit tests for client list checks and playlist building logic

Signed-off-by: Deluan <deluan@navidrome.org>

* fix: revert Child.IsVideo and Playlist.Public fields from pointer to boolean, and add omitempty to XML tag

Signed-off-by: Deluan <deluan@navidrome.org>

---------

Signed-off-by: Deluan <deluan@navidrome.org>
Co-authored-by: Deluan Quintão <deluan@navidrome.org>
2026-01-16 05:55:21 -05:00
51 changed files with 596 additions and 74 deletions

View File

@@ -153,6 +153,7 @@ type subsonicOptions struct {
ArtistParticipations bool
DefaultReportRealPath bool
LegacyClients string
MinimalClients string
}
type TagConf struct {

View File

@@ -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)
}

View File

@@ -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() {

View File

@@ -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

View File

@@ -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 {

View File

@@ -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
}

View File

@@ -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{

View File

@@ -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": {

View File

@@ -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)

View File

@@ -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{}

View File

@@ -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

View File

@@ -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
}

View File

@@ -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

View File

@@ -9,7 +9,6 @@
{
"id": "1",
"isDir": false,
"isVideo": false,
"bpm": 0,
"comment": "",
"sortName": "sort name",

View File

@@ -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>

View File

@@ -9,8 +9,7 @@
{
"id": "1",
"isDir": false,
"title": "title",
"isVideo": false
"title": "title"
}
]
}

View File

@@ -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>

View File

@@ -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": "",

View File

@@ -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 &amp; artist2" displayAlbumArtist="album artist1 &amp; album artist2" displayComposer="composer 1 &amp; 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 &amp; artist2" displayAlbumArtist="album artist1 &amp; album artist2" displayComposer="composer 1 &amp; 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>

View File

@@ -10,8 +10,7 @@
"entry": {
"id": "1",
"isDir": false,
"title": "title",
"isVideo": false
"title": "title"
},
"position": 123,
"username": "user2",

View File

@@ -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>

View File

@@ -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": "",

View File

@@ -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 &amp; artist 2" displayAlbumArtist="album artist 1 &amp; album artist 2" displayComposer="composer 1 &amp; 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 &amp; artist 2" displayAlbumArtist="album artist 1 &amp; album artist 2" displayComposer="composer 1 &amp; 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>

View File

@@ -8,8 +8,7 @@
"child": [
{
"id": "1",
"isDir": false,
"isVideo": false
"isDir": false
}
],
"id": "",

View File

@@ -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>

View File

@@ -9,7 +9,6 @@
{
"id": "1",
"isDir": false,
"isVideo": false,
"bpm": 0,
"comment": "",
"sortName": "",

View File

@@ -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>

View File

@@ -9,8 +9,7 @@
{
"id": "1",
"isDir": false,
"title": "title",
"isVideo": false
"title": "title"
}
],
"id": "1",

View File

@@ -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>

View File

@@ -9,8 +9,7 @@
{
"id": "1",
"isDir": false,
"title": "title",
"isVideo": false
"title": "title"
}
],
"current": "111",

View File

@@ -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>

View File

@@ -9,8 +9,7 @@
{
"id": "1",
"isDir": false,
"title": "title",
"isVideo": false
"title": "title"
}
],
"currentIndex": 0,

View File

@@ -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>

View File

@@ -23,7 +23,6 @@
"name": "bbb",
"songCount": 0,
"duration": 0,
"public": false,
"created": "0001-01-01T00:00:00Z",
"changed": "0001-01-01T00:00:00Z"
}

View File

@@ -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>

View File

@@ -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",

View File

@@ -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>

View File

@@ -9,8 +9,7 @@
{
"id": "1",
"isDir": false,
"title": "title",
"isVideo": false
"title": "title"
}
]
}

View File

@@ -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>

View File

@@ -9,8 +9,7 @@
{
"id": "1",
"isDir": false,
"title": "title",
"isVideo": false
"title": "title"
}
]
}

View File

@@ -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>

View File

@@ -9,8 +9,7 @@
{
"id": "1",
"isDir": false,
"title": "title",
"isVideo": false
"title": "title"
}
]
}

View File

@@ -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>

View File

@@ -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"`

View 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"
}

View File

@@ -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)

View File

@@ -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')

View File

@@ -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": {

View File

@@ -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 ? (

View File

@@ -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>

View File

@@ -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 }) => {