package subsonic import ( "context" "encoding/json" "time" "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/core/playlists" "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 _ playlists.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, 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("artwork emission", func() { It("suffixes coverArt with the content hash when resolved", func() { playlist.ImageHash = "0123456789abcdef" result := router.buildPlaylist(ctx, playlist) Expect(result.CoverArt).To(Equal("pl-pls-1_0123456789abcdef")) }) It("omits coverArt when known absent", func() { playlist.ImageAbsent = true result := router.buildPlaylist(ctx, playlist) Expect(result.CoverArt).To(BeEmpty()) }) }) Context("with legacy client", func() { BeforeEach(func() { conf.Server.Subsonic.LegacyClients = "legacy-client" player := model.Player{Client: "legacy-client"} ctx = request.WithPlayer(ctx, player) }) It("returns all standard fields but no OpenSubsonic extensions", func() { result := router.buildPlaylist(ctx, playlist) Expect(result.Comment).To(Equal("Test comment")) Expect(result.Owner).To(Equal("admin")) Expect(result.Public).To(BeTrue()) Expect(result.OpenSubsonicPlaylist).To(BeNil()) }) }) 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)) }) }) Context("with legacy client", func() { BeforeEach(func() { conf.Server.Subsonic.LegacyClients = "legacy-client" player := model.Player{Client: "legacy-client"} ctx = request.WithPlayer(ctx, player) }) It("returns standard fields but no OpenSubsonic extensions", func() { result := router.buildPlaylist(ctx, playlist) Expect(result.Comment).To(Equal("Test comment")) Expect(result.Owner).To(Equal("admin")) Expect(result.Public).To(BeTrue()) Expect(result.OpenSubsonicPlaylist).To(BeNil()) }) }) Context("with a per-playlist refreshDelay", func() { BeforeEach(func() { playlist.Rules.RefreshDelay = 24 * time.Hour player := model.Player{Client: "regular-client"} ctx = request.WithPlayer(ctx, player) }) It("computes validUntil from the playlist's own delay", func() { result := router.buildPlaylist(ctx, playlist) expected := evaluatedAt.Add(24 * time.Hour) Expect(result.ValidUntil).To(Equal(&expected)) }) }) }) Describe("annotation leakage", func() { It("does not serialize starred/rating even when the model carries them", func() { p := model.Playlist{ID: "pl-1", Name: "My Playlist"} p.Starred = true p.Rating = 5 resp := router.buildPlaylist(ctx, p) data, err := json.Marshal(resp) Expect(err).ToNot(HaveOccurred()) var fields map[string]any Expect(json.Unmarshal(data, &fields)).To(Succeed()) Expect(fields).ToNot(HaveKey("starred")) Expect(fields).ToNot(HaveKey("starredAt")) Expect(fields).ToNot(HaveKey("rating")) Expect(fields).ToNot(HaveKey("userRating")) Expect(fields).ToNot(HaveKey("averageRating")) Expect(fields).ToNot(HaveKey("playCount")) }) }) }) 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, 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 { playlists.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 }