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
+2
View File
@@ -157,6 +157,8 @@ func (n *Router) addQueueRoute(r chi.Router) {
r.Route("/queue", func(r chi.Router) {
r.Get("/", getQueue(n.ds))
r.Post("/", saveQueue(n.ds))
r.Put("/", updateQueue(n.ds))
r.Delete("/", clearQueue(n.ds))
})
}
+155 -17
View File
@@ -1,6 +1,7 @@
package nativeapi
import (
"context"
"encoding/json"
"errors"
"net/http"
@@ -8,13 +9,61 @@ import (
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/request"
. "github.com/navidrome/navidrome/utils/gg"
"github.com/navidrome/navidrome/utils/slice"
)
type queuePayload struct {
Ids []string `json:"ids"`
Current int `json:"current"`
Position int64 `json:"position"`
type updateQueuePayload struct {
Ids *[]string `json:"ids,omitempty"`
Current *int `json:"current,omitempty"`
Position *int64 `json:"position,omitempty"`
}
// validateCurrentIndex validates that the current index is within bounds of the items array.
// Returns false if validation fails (and sends error response), true if validation passes.
func validateCurrentIndex(w http.ResponseWriter, current int, itemsLength int) bool {
if current < 0 || current >= itemsLength {
http.Error(w, "current index out of bounds", http.StatusBadRequest)
return false
}
return true
}
// retrieveExistingQueue retrieves an existing play queue for a user with proper error handling.
// Returns the queue (nil if not found) and false if an error occurred and response was sent.
func retrieveExistingQueue(ctx context.Context, w http.ResponseWriter, ds model.DataStore, userID string) (*model.PlayQueue, bool) {
existing, err := ds.PlayQueue(ctx).Retrieve(userID)
if err != nil && !errors.Is(err, model.ErrNotFound) {
log.Error(ctx, "Error retrieving queue", err)
http.Error(w, err.Error(), http.StatusInternalServerError)
return nil, false
}
return existing, true
}
// decodeUpdatePayload decodes the JSON payload from the request body.
// Returns false if decoding fails (and sends error response), true if successful.
func decodeUpdatePayload(w http.ResponseWriter, r *http.Request) (*updateQueuePayload, bool) {
var payload updateQueuePayload
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return nil, false
}
return &payload, true
}
// createMediaFileItems converts a slice of IDs to MediaFile items.
func createMediaFileItems(ids []string) []model.MediaFile {
return slice.Map(ids, func(id string) model.MediaFile {
return model.MediaFile{ID: id}
})
}
// extractUserAndClient extracts user and client from the request context.
func extractUserAndClient(ctx context.Context) (model.User, string) {
user, _ := request.UserFrom(ctx)
client, _ := request.ClientFrom(ctx)
return user, client
}
func getQueue(ds model.DataStore) http.HandlerFunc {
@@ -22,7 +71,7 @@ func getQueue(ds model.DataStore) http.HandlerFunc {
ctx := r.Context()
user, _ := request.UserFrom(ctx)
repo := ds.PlayQueue(ctx)
pq, err := repo.Retrieve(user.ID)
pq, err := repo.RetrieveWithMediaFiles(user.ID)
if err != nil && !errors.Is(err, model.ErrNotFound) {
log.Error(ctx, "Error retrieving queue", err)
http.Error(w, err.Error(), http.StatusInternalServerError)
@@ -45,24 +94,21 @@ func getQueue(ds model.DataStore) http.HandlerFunc {
func saveQueue(ds model.DataStore) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
var payload queuePayload
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
payload, ok := decodeUpdatePayload(w, r)
if !ok {
return
}
user, _ := request.UserFrom(ctx)
client, _ := request.ClientFrom(ctx)
items := slice.Map(payload.Ids, func(id string) model.MediaFile {
return model.MediaFile{ID: id}
})
if len(payload.Ids) > 0 && (payload.Current < 0 || payload.Current >= len(payload.Ids)) {
http.Error(w, "current index out of bounds", http.StatusBadRequest)
user, client := extractUserAndClient(ctx)
ids := V(payload.Ids)
items := createMediaFileItems(ids)
current := V(payload.Current)
if len(ids) > 0 && !validateCurrentIndex(w, current, len(ids)) {
return
}
pq := &model.PlayQueue{
UserID: user.ID,
Current: payload.Current,
Position: max(payload.Position, 0),
Current: current,
Position: max(V(payload.Position), 0),
ChangedBy: client,
Items: items,
}
@@ -74,3 +120,95 @@ func saveQueue(ds model.DataStore) http.HandlerFunc {
w.WriteHeader(http.StatusNoContent)
}
}
func updateQueue(ds model.DataStore) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
// Decode and validate the JSON payload
payload, ok := decodeUpdatePayload(w, r)
if !ok {
return
}
// Extract user and client information from request context
user, client := extractUserAndClient(ctx)
// Initialize play queue with user ID and client info
pq := &model.PlayQueue{UserID: user.ID, ChangedBy: client}
var cols []string // Track which columns to update in the database
// Handle queue items update
if payload.Ids != nil {
pq.Items = createMediaFileItems(*payload.Ids)
cols = append(cols, "items")
// If current index is not being updated, validate existing current index
// against the new items list to ensure it remains valid
if payload.Current == nil {
existing, ok := retrieveExistingQueue(ctx, w, ds, user.ID)
if !ok {
return
}
if existing != nil && !validateCurrentIndex(w, existing.Current, len(*payload.Ids)) {
return
}
}
}
// Handle current track index update
if payload.Current != nil {
pq.Current = *payload.Current
cols = append(cols, "current")
if payload.Ids != nil {
// If items are also being updated, validate current index against new items
if !validateCurrentIndex(w, *payload.Current, len(*payload.Ids)) {
return
}
} else {
// If only current index is being updated, validate against existing items
existing, ok := retrieveExistingQueue(ctx, w, ds, user.ID)
if !ok {
return
}
if existing != nil && !validateCurrentIndex(w, *payload.Current, len(existing.Items)) {
return
}
}
}
// Handle playback position update
if payload.Position != nil {
pq.Position = max(*payload.Position, 0) // Ensure position is non-negative
cols = append(cols, "position")
}
// If no fields were specified for update, return success without doing anything
if len(cols) == 0 {
w.WriteHeader(http.StatusNoContent)
return
}
// Perform partial update of the specified columns only
if err := ds.PlayQueue(ctx).Store(pq, cols...); err != nil {
log.Error(ctx, "Error updating queue", err)
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusNoContent)
}
}
func clearQueue(ds model.DataStore) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
user, _ := request.UserFrom(ctx)
if err := ds.PlayQueue(ctx).Clear(user.ID); err != nil {
log.Error(ctx, "Error clearing queue", err)
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusNoContent)
}
}
+123 -5
View File
@@ -9,6 +9,7 @@ import (
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/request"
"github.com/navidrome/navidrome/tests"
"github.com/navidrome/navidrome/utils/gg"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
@@ -31,7 +32,7 @@ var _ = Describe("Queue Endpoints", func() {
Describe("POST /queue", func() {
It("saves the queue", func() {
payload := queuePayload{Ids: []string{"s1", "s2"}, Current: 1, Position: 10}
payload := updateQueuePayload{Ids: gg.P([]string{"s1", "s2"}), Current: gg.P(1), Position: gg.P(int64(10))}
body, _ := json.Marshal(payload)
req := httptest.NewRequest("POST", "/queue", bytes.NewReader(body))
ctx := request.WithUser(req.Context(), user)
@@ -49,7 +50,7 @@ var _ = Describe("Queue Endpoints", func() {
})
It("saves an empty queue", func() {
payload := queuePayload{Ids: []string{}, Current: 0, Position: 0}
payload := updateQueuePayload{Ids: gg.P([]string{}), Current: gg.P(0), Position: gg.P(int64(0))}
body, _ := json.Marshal(payload)
req := httptest.NewRequest("POST", "/queue", bytes.NewReader(body))
req = req.WithContext(request.WithUser(req.Context(), user))
@@ -62,7 +63,7 @@ var _ = Describe("Queue Endpoints", func() {
})
It("returns bad request for invalid current index (negative)", func() {
payload := queuePayload{Ids: []string{"s1", "s2"}, Current: -1, Position: 10}
payload := updateQueuePayload{Ids: gg.P([]string{"s1", "s2"}), Current: gg.P(-1), Position: gg.P(int64(10))}
body, _ := json.Marshal(payload)
req := httptest.NewRequest("POST", "/queue", bytes.NewReader(body))
req = req.WithContext(request.WithUser(req.Context(), user))
@@ -74,7 +75,7 @@ var _ = Describe("Queue Endpoints", func() {
})
It("returns bad request for invalid current index (too large)", func() {
payload := queuePayload{Ids: []string{"s1", "s2"}, Current: 2, Position: 10}
payload := updateQueuePayload{Ids: gg.P([]string{"s1", "s2"}), Current: gg.P(2), Position: gg.P(int64(10))}
body, _ := json.Marshal(payload)
req := httptest.NewRequest("POST", "/queue", bytes.NewReader(body))
req = req.WithContext(request.WithUser(req.Context(), user))
@@ -96,7 +97,7 @@ var _ = Describe("Queue Endpoints", func() {
It("returns internal server error when store fails", func() {
repo.Err = true
payload := queuePayload{Ids: []string{"s1"}, Current: 0, Position: 10}
payload := updateQueuePayload{Ids: gg.P([]string{"s1"}), Current: gg.P(0), Position: gg.P(int64(10))}
body, _ := json.Marshal(payload)
req := httptest.NewRequest("POST", "/queue", bytes.NewReader(body))
req = req.WithContext(request.WithUser(req.Context(), user))
@@ -161,4 +162,121 @@ var _ = Describe("Queue Endpoints", func() {
Expect(w.Code).To(Equal(http.StatusInternalServerError))
})
})
Describe("PUT /queue", func() {
It("updates the queue fields", func() {
repo.Queue = &model.PlayQueue{UserID: user.ID, Items: model.MediaFiles{{ID: "s1"}, {ID: "s2"}, {ID: "s3"}}}
payload := updateQueuePayload{Current: gg.P(2), Position: gg.P(int64(20))}
body, _ := json.Marshal(payload)
req := httptest.NewRequest("PUT", "/queue", bytes.NewReader(body))
ctx := request.WithUser(req.Context(), user)
ctx = request.WithClient(ctx, "TestClient")
req = req.WithContext(ctx)
w := httptest.NewRecorder()
updateQueue(ds)(w, req)
Expect(w.Code).To(Equal(http.StatusNoContent))
Expect(repo.Queue).ToNot(BeNil())
Expect(repo.Queue.Current).To(Equal(2))
Expect(repo.Queue.Position).To(Equal(int64(20)))
Expect(repo.Queue.ChangedBy).To(Equal("TestClient"))
})
It("updates only ids", func() {
repo.Queue = &model.PlayQueue{UserID: user.ID, Current: 1}
payload := updateQueuePayload{Ids: gg.P([]string{"s1", "s2"})}
body, _ := json.Marshal(payload)
req := httptest.NewRequest("PUT", "/queue", bytes.NewReader(body))
req = req.WithContext(request.WithUser(req.Context(), user))
w := httptest.NewRecorder()
updateQueue(ds)(w, req)
Expect(w.Code).To(Equal(http.StatusNoContent))
Expect(repo.Queue.Items).To(HaveLen(2))
Expect(repo.LastCols).To(ConsistOf("items"))
})
It("updates ids and current", func() {
repo.Queue = &model.PlayQueue{UserID: user.ID}
payload := updateQueuePayload{Ids: gg.P([]string{"s1", "s2"}), Current: gg.P(1)}
body, _ := json.Marshal(payload)
req := httptest.NewRequest("PUT", "/queue", bytes.NewReader(body))
req = req.WithContext(request.WithUser(req.Context(), user))
w := httptest.NewRecorder()
updateQueue(ds)(w, req)
Expect(w.Code).To(Equal(http.StatusNoContent))
Expect(repo.Queue.Items).To(HaveLen(2))
Expect(repo.Queue.Current).To(Equal(1))
Expect(repo.LastCols).To(ConsistOf("items", "current"))
})
It("returns bad request when new ids invalidate current", func() {
repo.Queue = &model.PlayQueue{UserID: user.ID, Current: 2}
payload := updateQueuePayload{Ids: gg.P([]string{"s1", "s2"})}
body, _ := json.Marshal(payload)
req := httptest.NewRequest("PUT", "/queue", bytes.NewReader(body))
req = req.WithContext(request.WithUser(req.Context(), user))
w := httptest.NewRecorder()
updateQueue(ds)(w, req)
Expect(w.Code).To(Equal(http.StatusBadRequest))
})
It("returns bad request when current out of bounds", func() {
repo.Queue = &model.PlayQueue{UserID: user.ID, Items: model.MediaFiles{{ID: "s1"}}}
payload := updateQueuePayload{Current: gg.P(3)}
body, _ := json.Marshal(payload)
req := httptest.NewRequest("PUT", "/queue", bytes.NewReader(body))
req = req.WithContext(request.WithUser(req.Context(), user))
w := httptest.NewRecorder()
updateQueue(ds)(w, req)
Expect(w.Code).To(Equal(http.StatusBadRequest))
})
It("returns bad request for malformed JSON", func() {
req := httptest.NewRequest("PUT", "/queue", bytes.NewReader([]byte("{")))
req = req.WithContext(request.WithUser(req.Context(), user))
w := httptest.NewRecorder()
updateQueue(ds)(w, req)
Expect(w.Code).To(Equal(http.StatusBadRequest))
})
It("returns internal server error when store fails", func() {
repo.Err = true
payload := updateQueuePayload{Position: gg.P(int64(10))}
body, _ := json.Marshal(payload)
req := httptest.NewRequest("PUT", "/queue", bytes.NewReader(body))
req = req.WithContext(request.WithUser(req.Context(), user))
w := httptest.NewRecorder()
updateQueue(ds)(w, req)
Expect(w.Code).To(Equal(http.StatusInternalServerError))
})
})
Describe("DELETE /queue", func() {
It("clears the queue", func() {
repo.Queue = &model.PlayQueue{UserID: user.ID, Items: model.MediaFiles{{ID: "s1"}}}
req := httptest.NewRequest("DELETE", "/queue", nil)
req = req.WithContext(request.WithUser(req.Context(), user))
w := httptest.NewRecorder()
clearQueue(ds)(w, req)
Expect(w.Code).To(Equal(http.StatusNoContent))
Expect(repo.Queue).To(BeNil())
})
It("returns internal server error when clear fails", func() {
repo.Err = true
req := httptest.NewRequest("DELETE", "/queue", nil)
req = req.WithContext(request.WithUser(req.Context(), user))
w := httptest.NewRecorder()
clearQueue(ds)(w, req)
Expect(w.Code).To(Equal(http.StatusInternalServerError))
})
})
})