feat(server): add update and clear play queue endpoints to native API (#4215)

* Refactor queue payload handling

* Refine queue update validation

* refactor(queue): avoid loading tracks for validation

* refactor/rename repository methods

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

* more tests

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

* refactor

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

---------

Signed-off-by: Deluan <deluan@navidrome.org>
This commit is contained in:
Deluan Quintão
2025-06-11 12:02:31 -04:00
committed by GitHub
parent 356caa93c7
commit 410e457e5a
8 changed files with 616 additions and 51 deletions

View File

@@ -35,22 +35,27 @@ type playQueue struct {
UpdatedAt time.Time `structs:"updated_at"`
}
func (r *playQueueRepository) Store(q *model.PlayQueue) error {
func (r *playQueueRepository) Store(q *model.PlayQueue, colNames ...string) error {
u := loggedUser(r.ctx)
err := r.clearPlayQueue(q.UserID)
if err != nil {
log.Error(r.ctx, "Error deleting previous playqueue", "user", u.UserName, err)
return err
}
if len(q.Items) == 0 {
return nil
// When no specific columns are provided, we replace the whole queue
if len(colNames) == 0 {
err := r.clearPlayQueue(q.UserID)
if err != nil {
log.Error(r.ctx, "Error deleting previous playqueue", "user", u.UserName, err)
return err
}
if len(q.Items) == 0 {
return nil
}
}
pq := r.fromModel(q)
if pq.ID == "" {
pq.CreatedAt = time.Now()
}
pq.UpdatedAt = time.Now()
_, err = r.put(pq.ID, pq)
_, err := r.put(pq.ID, pq, colNames...)
if err != nil {
log.Error(r.ctx, "Error saving playqueue", "user", u.UserName, err)
return err
@@ -58,12 +63,21 @@ func (r *playQueueRepository) Store(q *model.PlayQueue) error {
return nil
}
func (r *playQueueRepository) RetrieveWithMediaFiles(userId string) (*model.PlayQueue, error) {
sel := r.newSelect().Columns("*").Where(Eq{"user_id": userId})
var res playQueue
err := r.queryOne(sel, &res)
q := r.toModel(&res)
q.Items = r.loadTracks(q.Items)
return &q, err
}
func (r *playQueueRepository) Retrieve(userId string) (*model.PlayQueue, error) {
sel := r.newSelect().Columns("*").Where(Eq{"user_id": userId})
var res playQueue
err := r.queryOne(sel, &res)
pls := r.toModel(&res)
return &pls, err
q := r.toModel(&res)
return &q, err
}
func (r *playQueueRepository) fromModel(q *model.PlayQueue) playQueue {
@@ -100,7 +114,6 @@ func (r *playQueueRepository) toModel(pq *playQueue) model.PlayQueue {
q.Items = append(q.Items, model.MediaFile{ID: t})
}
}
q.Items = r.loadTracks(q.Items)
return q
}
@@ -145,4 +158,8 @@ func (r *playQueueRepository) clearPlayQueue(userId string) error {
return r.delete(Eq{"user_id": userId})
}
func (r *playQueueRepository) Clear(userId string) error {
return r.clearPlayQueue(userId)
}
var _ model.PlayQueueRepository = (*playQueueRepository)(nil)

View File

@@ -5,6 +5,7 @@ import (
"time"
"github.com/Masterminds/squirrel"
"github.com/navidrome/navidrome/conf/configtest"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/id"
@@ -18,18 +19,165 @@ var _ = Describe("PlayQueueRepository", func() {
var ctx context.Context
BeforeEach(func() {
DeferCleanup(configtest.SetupConfig())
ctx = log.NewContext(context.TODO())
ctx = request.WithUser(ctx, model.User{ID: "userid", UserName: "userid", IsAdmin: true})
repo = NewPlayQueueRepository(ctx, GetDBXBuilder())
})
Describe("PlayQueues", func() {
Describe("Store", func() {
It("stores a complete playqueue", func() {
expected := aPlayQueue("userid", 1, 123, songComeTogether, songDayInALife)
Expect(repo.Store(expected)).To(Succeed())
actual, err := repo.RetrieveWithMediaFiles("userid")
Expect(err).ToNot(HaveOccurred())
AssertPlayQueue(expected, actual)
Expect(countPlayQueues(repo, "userid")).To(Equal(1))
})
It("replaces existing playqueue when storing without column names", func() {
By("Storing initial playqueue")
initial := aPlayQueue("userid", 0, 100, songComeTogether)
Expect(repo.Store(initial)).To(Succeed())
By("Storing replacement playqueue")
replacement := aPlayQueue("userid", 1, 200, songDayInALife, songAntenna)
Expect(repo.Store(replacement)).To(Succeed())
actual, err := repo.RetrieveWithMediaFiles("userid")
Expect(err).ToNot(HaveOccurred())
AssertPlayQueue(replacement, actual)
Expect(countPlayQueues(repo, "userid")).To(Equal(1))
})
It("clears playqueue when storing empty items", func() {
By("Storing initial playqueue")
initial := aPlayQueue("userid", 0, 100, songComeTogether)
Expect(repo.Store(initial)).To(Succeed())
By("Storing empty playqueue")
empty := aPlayQueue("userid", 0, 0)
Expect(repo.Store(empty)).To(Succeed())
By("Verifying playqueue is cleared")
_, err := repo.Retrieve("userid")
Expect(err).To(MatchError(model.ErrNotFound))
})
It("updates only current field when specified", func() {
By("Storing initial playqueue")
initial := aPlayQueue("userid", 0, 100, songComeTogether, songDayInALife)
Expect(repo.Store(initial)).To(Succeed())
By("Getting the existing playqueue to obtain its ID")
existing, err := repo.Retrieve("userid")
Expect(err).ToNot(HaveOccurred())
By("Updating only current field")
update := &model.PlayQueue{
ID: existing.ID, // Use existing ID for partial update
UserID: "userid",
Current: 1,
ChangedBy: "test-update",
}
Expect(repo.Store(update, "current")).To(Succeed())
By("Verifying only current was updated")
actual, err := repo.Retrieve("userid")
Expect(err).ToNot(HaveOccurred())
Expect(actual.Current).To(Equal(1))
Expect(actual.Position).To(Equal(int64(100))) // Should remain unchanged
Expect(actual.Items).To(HaveLen(2)) // Should remain unchanged
})
It("updates only position field when specified", func() {
By("Storing initial playqueue")
initial := aPlayQueue("userid", 1, 100, songComeTogether, songDayInALife)
Expect(repo.Store(initial)).To(Succeed())
By("Getting the existing playqueue to obtain its ID")
existing, err := repo.Retrieve("userid")
Expect(err).ToNot(HaveOccurred())
By("Updating only position field")
update := &model.PlayQueue{
ID: existing.ID, // Use existing ID for partial update
UserID: "userid",
Position: 500,
ChangedBy: "test-update",
}
Expect(repo.Store(update, "position")).To(Succeed())
By("Verifying only position was updated")
actual, err := repo.Retrieve("userid")
Expect(err).ToNot(HaveOccurred())
Expect(actual.Position).To(Equal(int64(500)))
Expect(actual.Current).To(Equal(1)) // Should remain unchanged
Expect(actual.Items).To(HaveLen(2)) // Should remain unchanged
})
It("updates multiple specified fields", func() {
By("Storing initial playqueue")
initial := aPlayQueue("userid", 0, 100, songComeTogether)
Expect(repo.Store(initial)).To(Succeed())
By("Getting the existing playqueue to obtain its ID")
existing, err := repo.Retrieve("userid")
Expect(err).ToNot(HaveOccurred())
By("Updating current and position fields")
update := &model.PlayQueue{
ID: existing.ID, // Use existing ID for partial update
UserID: "userid",
Current: 1,
Position: 300,
ChangedBy: "test-update",
}
Expect(repo.Store(update, "current", "position")).To(Succeed())
By("Verifying both fields were updated")
actual, err := repo.Retrieve("userid")
Expect(err).ToNot(HaveOccurred())
Expect(actual.Current).To(Equal(1))
Expect(actual.Position).To(Equal(int64(300)))
Expect(actual.Items).To(HaveLen(1)) // Should remain unchanged
})
It("preserves existing data when updating with empty items list and column names", func() {
By("Storing initial playqueue")
initial := aPlayQueue("userid", 0, 100, songComeTogether, songDayInALife)
Expect(repo.Store(initial)).To(Succeed())
By("Getting the existing playqueue to obtain its ID")
existing, err := repo.Retrieve("userid")
Expect(err).ToNot(HaveOccurred())
By("Updating only position with empty items")
update := &model.PlayQueue{
ID: existing.ID, // Use existing ID for partial update
UserID: "userid",
Position: 200,
ChangedBy: "test-update",
Items: []model.MediaFile{}, // Empty items
}
Expect(repo.Store(update, "position")).To(Succeed())
By("Verifying items are preserved")
actual, err := repo.Retrieve("userid")
Expect(err).ToNot(HaveOccurred())
Expect(actual.Position).To(Equal(int64(200)))
Expect(actual.Items).To(HaveLen(2)) // Should remain unchanged
})
})
Describe("Retrieve", func() {
It("returns notfound error if there's no playqueue for the user", func() {
_, err := repo.Retrieve("user999")
Expect(err).To(MatchError(model.ErrNotFound))
})
It("stores and retrieves the playqueue for the user", func() {
It("retrieves the playqueue with only track IDs (no full MediaFile data)", func() {
By("Storing a playqueue for the user")
expected := aPlayQueue("userid", 1, 123, songComeTogether, songDayInALife)
@@ -38,18 +186,76 @@ var _ = Describe("PlayQueueRepository", func() {
actual, err := repo.Retrieve("userid")
Expect(err).ToNot(HaveOccurred())
AssertPlayQueue(expected, actual)
// Basic playqueue properties should match
Expect(actual.ID).To(Equal(expected.ID))
Expect(actual.UserID).To(Equal(expected.UserID))
Expect(actual.Current).To(Equal(expected.Current))
Expect(actual.Position).To(Equal(expected.Position))
Expect(actual.ChangedBy).To(Equal(expected.ChangedBy))
Expect(actual.Items).To(HaveLen(len(expected.Items)))
By("Storing a new playqueue for the same user")
// Items should only contain IDs, not full MediaFile data
for i, item := range actual.Items {
Expect(item.ID).To(Equal(expected.Items[i].ID))
// These fields should be empty since we're not loading full MediaFiles
Expect(item.Title).To(BeEmpty())
Expect(item.Path).To(BeEmpty())
Expect(item.Album).To(BeEmpty())
Expect(item.Artist).To(BeEmpty())
}
})
another := aPlayQueue("userid", 1, 321, songAntenna, songRadioactivity)
Expect(repo.Store(another)).To(Succeed())
It("returns items with IDs even when some tracks don't exist in the DB", func() {
// Add a new song to the DB
newSong := songRadioactivity
newSong.ID = "temp-track"
newSong.Path = "/new-path"
mfRepo := NewMediaFileRepository(ctx, GetDBXBuilder())
actual, err = repo.Retrieve("userid")
Expect(mfRepo.Put(&newSong)).To(Succeed())
// Create a playqueue with the new song
pq := aPlayQueue("userid", 0, 0, newSong, songAntenna)
Expect(repo.Store(pq)).To(Succeed())
// Delete the new song from the database
Expect(mfRepo.Delete("temp-track")).To(Succeed())
// Retrieve the playqueue with Retrieve method
actual, err := repo.Retrieve("userid")
Expect(err).ToNot(HaveOccurred())
AssertPlayQueue(another, actual)
Expect(countPlayQueues(repo, "userid")).To(Equal(1))
// The playqueue should still contain both track IDs (including the deleted one)
Expect(actual.Items).To(HaveLen(2))
Expect(actual.Items[0].ID).To(Equal("temp-track"))
Expect(actual.Items[1].ID).To(Equal(songAntenna.ID))
// Items should only contain IDs, no other data
for _, item := range actual.Items {
Expect(item.Title).To(BeEmpty())
Expect(item.Path).To(BeEmpty())
Expect(item.Album).To(BeEmpty())
Expect(item.Artist).To(BeEmpty())
}
})
})
Describe("RetrieveWithMediaFiles", func() {
It("returns notfound error if there's no playqueue for the user", func() {
_, err := repo.RetrieveWithMediaFiles("user999")
Expect(err).To(MatchError(model.ErrNotFound))
})
It("retrieves the playqueue with full MediaFile data", func() {
By("Storing a playqueue for the user")
expected := aPlayQueue("userid", 1, 123, songComeTogether, songDayInALife)
Expect(repo.Store(expected)).To(Succeed())
actual, err := repo.RetrieveWithMediaFiles("userid")
Expect(err).ToNot(HaveOccurred())
AssertPlayQueue(expected, actual)
})
It("does not return tracks if they don't exist in the DB", func() {
@@ -66,7 +272,7 @@ var _ = Describe("PlayQueueRepository", func() {
Expect(repo.Store(pq)).To(Succeed())
// Retrieve the playqueue
actual, err := repo.Retrieve("userid")
actual, err := repo.RetrieveWithMediaFiles("userid")
Expect(err).ToNot(HaveOccurred())
// The playqueue should contain both tracks
@@ -76,7 +282,7 @@ var _ = Describe("PlayQueueRepository", func() {
Expect(mfRepo.Delete("temp-track")).To(Succeed())
// Retrieve the playqueue
actual, err = repo.Retrieve("userid")
actual, err = repo.RetrieveWithMediaFiles("userid")
Expect(err).ToNot(HaveOccurred())
// The playqueue should not contain the deleted track
@@ -84,6 +290,59 @@ var _ = Describe("PlayQueueRepository", func() {
Expect(actual.Items[0].ID).To(Equal(songAntenna.ID))
})
})
Describe("Clear", func() {
It("clears an existing playqueue", func() {
By("Storing a playqueue")
expected := aPlayQueue("userid", 1, 123, songComeTogether, songDayInALife)
Expect(repo.Store(expected)).To(Succeed())
By("Verifying playqueue exists")
_, err := repo.Retrieve("userid")
Expect(err).ToNot(HaveOccurred())
By("Clearing the playqueue")
Expect(repo.Clear("userid")).To(Succeed())
By("Verifying playqueue is cleared")
_, err = repo.Retrieve("userid")
Expect(err).To(MatchError(model.ErrNotFound))
})
It("does not error when clearing non-existent playqueue", func() {
// Clear should not error even if no playqueue exists
Expect(repo.Clear("nonexistent-user")).To(Succeed())
})
It("only clears the specified user's playqueue", func() {
By("Creating users in the database to avoid foreign key constraints")
userRepo := NewUserRepository(ctx, GetDBXBuilder())
user1 := &model.User{ID: "user1", UserName: "user1", Name: "User 1", Email: "user1@test.com"}
user2 := &model.User{ID: "user2", UserName: "user2", Name: "User 2", Email: "user2@test.com"}
Expect(userRepo.Put(user1)).To(Succeed())
Expect(userRepo.Put(user2)).To(Succeed())
By("Storing playqueues for two users")
user1Queue := aPlayQueue("user1", 0, 100, songComeTogether)
user2Queue := aPlayQueue("user2", 1, 200, songDayInALife)
Expect(repo.Store(user1Queue)).To(Succeed())
Expect(repo.Store(user2Queue)).To(Succeed())
By("Clearing only user1's playqueue")
Expect(repo.Clear("user1")).To(Succeed())
By("Verifying user1's playqueue is cleared")
_, err := repo.Retrieve("user1")
Expect(err).To(MatchError(model.ErrNotFound))
By("Verifying user2's playqueue still exists")
actual, err := repo.Retrieve("user2")
Expect(err).ToNot(HaveOccurred())
Expect(actual.UserID).To(Equal("user2"))
Expect(actual.Current).To(Equal(1))
Expect(actual.Position).To(Equal(int64(200)))
})
})
})
func countPlayQueues(repo model.PlayQueueRepository, userId string) int {