Files
navidrome/server/subsonic/playlists_test.go
Kendall Garner 6fb4cd277e feat(subsonic): add OS readonly and validUntil properties in playlists (#4993)
* feat(subsonic): add OS readonly and validUntil properties

* remove duplicated test

* test: fix and enable disabled child smart playlist tests

Fixed the XContext("child smart playlists") tests that were disabled with
a TODO comment. The tests had several issues: nested playlists were missing
Public: true (required by InPlaylist criteria), the criteria matched no
test fixtures, the "not expired" test set EvaluatedAt on the parent too
(preventing it from refreshing at all), and the "expired" test dereferenced
a nil EvaluatedAt. Added proper cleanup with DeferCleanup and config
restoration via configtest.

* fix(subsonic): always include readonly field in JSON playlist responses

Removed omitempty from the JSON tag of the Readonly field in
OpenSubsonicPlaylist so that readonly: false is always serialized in
JSON responses, per the OpenSubsonic spec requirement that supported
fields must be returned with default values. Added a test case with an
empty OpenSubsonicPlaylist to verify the behavior.

---------

Co-authored-by: Deluan Quintão <deluan@navidrome.org>
2026-02-06 19:35:54 -05:00

293 lines
8.9 KiB
Go

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/criteria"
"github.com/navidrome/navidrome/model/request"
"github.com/navidrome/navidrome/tests"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
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()
})
Describe("normal playlist", func() {
BeforeEach(func() {
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",
OwnerID: "1234",
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())
Expect(result.Readonly).To(BeTrue())
})
It("returns all fields when as owner", func() {
ctx = request.WithUser(ctx, model.User{ID: "1234", UserName: "admin"})
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())
Expect(result.Readonly).To(BeFalse())
})
})
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())
})
})
})
Describe("smart playlist", func() {
evaluatedAt := time.Date(2023, 2, 20, 15, 45, 0, 0, time.UTC)
validUntil := evaluatedAt.Add(5 * time.Second)
BeforeEach(func() {
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",
OwnerID: "1234",
Public: true,
SongCount: 10,
Duration: 600,
CreatedAt: createdAt,
UpdatedAt: updatedAt,
EvaluatedAt: &evaluatedAt,
Rules: &criteria.Criteria{
Expression: criteria.All{criteria.Contains{"title": "title"}},
},
}
})
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(evaluatedAt))
// 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())
Expect(result.OpenSubsonicPlaylist).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() {
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.EvaluatedAt))
Expect(result.Comment).To(Equal("Test comment"))
Expect(result.Owner).To(Equal("admin"))
Expect(result.Public).To(BeTrue())
Expect(result.Readonly).To(BeTrue())
Expect(result.ValidUntil).To(Equal(&validUntil))
})
})
})
})
var _ = Describe("UpdatePlaylist", func() {
var router *Router
var ds model.DataStore
var playlists *fakePlaylists
BeforeEach(func() {
ds = &tests.MockDataStore{}
playlists = &fakePlaylists{}
router = New(ds, nil, nil, nil, nil, nil, nil, nil, playlists, nil, nil, nil, nil)
})
It("clears the comment when parameter is empty", func() {
r := newGetRequest("playlistId=123", "comment=")
_, err := router.UpdatePlaylist(r)
Expect(err).ToNot(HaveOccurred())
Expect(playlists.lastPlaylistID).To(Equal("123"))
Expect(playlists.lastComment).ToNot(BeNil())
Expect(*playlists.lastComment).To(Equal(""))
})
It("leaves comment unchanged when parameter is missing", func() {
r := newGetRequest("playlistId=123")
_, err := router.UpdatePlaylist(r)
Expect(err).ToNot(HaveOccurred())
Expect(playlists.lastPlaylistID).To(Equal("123"))
Expect(playlists.lastComment).To(BeNil())
})
It("sets public to true when parameter is 'true'", func() {
r := newGetRequest("playlistId=123", "public=true")
_, err := router.UpdatePlaylist(r)
Expect(err).ToNot(HaveOccurred())
Expect(playlists.lastPlaylistID).To(Equal("123"))
Expect(playlists.lastPublic).ToNot(BeNil())
Expect(*playlists.lastPublic).To(BeTrue())
})
It("sets public to false when parameter is 'false'", func() {
r := newGetRequest("playlistId=123", "public=false")
_, err := router.UpdatePlaylist(r)
Expect(err).ToNot(HaveOccurred())
Expect(playlists.lastPlaylistID).To(Equal("123"))
Expect(playlists.lastPublic).ToNot(BeNil())
Expect(*playlists.lastPublic).To(BeFalse())
})
It("leaves public unchanged when parameter is missing", func() {
r := newGetRequest("playlistId=123")
_, err := router.UpdatePlaylist(r)
Expect(err).ToNot(HaveOccurred())
Expect(playlists.lastPlaylistID).To(Equal("123"))
Expect(playlists.lastPublic).To(BeNil())
})
})
type fakePlaylists struct {
core.Playlists
lastPlaylistID string
lastName *string
lastComment *string
lastPublic *bool
lastAdd []string
lastRemove []int
}
func (f *fakePlaylists) Update(ctx context.Context, playlistID string, name *string, comment *string, public *bool, idsToAdd []string, idxToRemove []int) error {
f.lastPlaylistID = playlistID
f.lastName = name
f.lastComment = comment
f.lastPublic = public
f.lastAdd = idsToAdd
f.lastRemove = idxToRemove
return nil
}