mirror of
https://github.com/navidrome/navidrome.git
synced 2025-12-23 23:18:05 -05:00
feat(server): add index-based play queue endpoints to native API (#4210)
* Add migration converting playqueue current to index * refactor Signed-off-by: Deluan <deluan@navidrome.org> * fix(queue): ensure valid current index and improve test coverage Signed-off-by: Deluan <deluan@navidrome.org> --------- Signed-off-by: Deluan <deluan@navidrome.org>
This commit is contained in:
@@ -60,6 +60,7 @@ func (n *Router) routes() http.Handler {
|
||||
n.addPlaylistRoute(r)
|
||||
n.addPlaylistTrackRoute(r)
|
||||
n.addSongPlaylistsRoute(r)
|
||||
n.addQueueRoute(r)
|
||||
n.addMissingFilesRoute(r)
|
||||
n.addInspectRoute(r)
|
||||
n.addConfigRoute(r)
|
||||
@@ -152,6 +153,13 @@ func (n *Router) addSongPlaylistsRoute(r chi.Router) {
|
||||
})
|
||||
}
|
||||
|
||||
func (n *Router) addQueueRoute(r chi.Router) {
|
||||
r.Route("/queue", func(r chi.Router) {
|
||||
r.Get("/", getQueue(n.ds))
|
||||
r.Post("/", saveQueue(n.ds))
|
||||
})
|
||||
}
|
||||
|
||||
func (n *Router) addMissingFilesRoute(r chi.Router) {
|
||||
r.Route("/missing", func(r chi.Router) {
|
||||
n.RX(r, "/", newMissingRepository(n.ds), false)
|
||||
|
||||
76
server/nativeapi/queue.go
Normal file
76
server/nativeapi/queue.go
Normal file
@@ -0,0 +1,76 @@
|
||||
package nativeapi
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/request"
|
||||
"github.com/navidrome/navidrome/utils/slice"
|
||||
)
|
||||
|
||||
type queuePayload struct {
|
||||
Ids []string `json:"ids"`
|
||||
Current int `json:"current"`
|
||||
Position int64 `json:"position"`
|
||||
}
|
||||
|
||||
func getQueue(ds model.DataStore) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
user, _ := request.UserFrom(ctx)
|
||||
repo := ds.PlayQueue(ctx)
|
||||
pq, err := repo.Retrieve(user.ID)
|
||||
if err != nil && !errors.Is(err, model.ErrNotFound) {
|
||||
log.Error(ctx, "Error retrieving queue", err)
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if pq == nil {
|
||||
pq = &model.PlayQueue{}
|
||||
}
|
||||
resp, err := json.Marshal(pq)
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error marshalling queue", err)
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write(resp)
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
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)
|
||||
return
|
||||
}
|
||||
pq := &model.PlayQueue{
|
||||
UserID: user.ID,
|
||||
Current: payload.Current,
|
||||
Position: max(payload.Position, 0),
|
||||
ChangedBy: client,
|
||||
Items: items,
|
||||
}
|
||||
if err := ds.PlayQueue(ctx).Store(pq); err != nil {
|
||||
log.Error(ctx, "Error saving queue", err)
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
}
|
||||
164
server/nativeapi/queue_test.go
Normal file
164
server/nativeapi/queue_test.go
Normal file
@@ -0,0 +1,164 @@
|
||||
package nativeapi
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
|
||||
"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"
|
||||
)
|
||||
|
||||
var _ = Describe("Queue Endpoints", func() {
|
||||
var (
|
||||
ds *tests.MockDataStore
|
||||
repo *tests.MockPlayQueueRepo
|
||||
user model.User
|
||||
userRepo *tests.MockedUserRepo
|
||||
)
|
||||
|
||||
BeforeEach(func() {
|
||||
repo = &tests.MockPlayQueueRepo{}
|
||||
user = model.User{ID: "u1", UserName: "user"}
|
||||
userRepo = tests.CreateMockUserRepo()
|
||||
_ = userRepo.Put(&user)
|
||||
ds = &tests.MockDataStore{MockedPlayQueue: repo, MockedUser: userRepo, MockedProperty: &tests.MockedPropertyRepo{}}
|
||||
})
|
||||
|
||||
Describe("POST /queue", func() {
|
||||
It("saves the queue", func() {
|
||||
payload := queuePayload{Ids: []string{"s1", "s2"}, Current: 1, Position: 10}
|
||||
body, _ := json.Marshal(payload)
|
||||
req := httptest.NewRequest("POST", "/queue", bytes.NewReader(body))
|
||||
ctx := request.WithUser(req.Context(), user)
|
||||
ctx = request.WithClient(ctx, "TestClient")
|
||||
req = req.WithContext(ctx)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
saveQueue(ds)(w, req)
|
||||
Expect(w.Code).To(Equal(http.StatusNoContent))
|
||||
Expect(repo.Queue).ToNot(BeNil())
|
||||
Expect(repo.Queue.Current).To(Equal(1))
|
||||
Expect(repo.Queue.Items).To(HaveLen(2))
|
||||
Expect(repo.Queue.Items[1].ID).To(Equal("s2"))
|
||||
Expect(repo.Queue.ChangedBy).To(Equal("TestClient"))
|
||||
})
|
||||
|
||||
It("saves an empty queue", func() {
|
||||
payload := queuePayload{Ids: []string{}, Current: 0, Position: 0}
|
||||
body, _ := json.Marshal(payload)
|
||||
req := httptest.NewRequest("POST", "/queue", bytes.NewReader(body))
|
||||
req = req.WithContext(request.WithUser(req.Context(), user))
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
saveQueue(ds)(w, req)
|
||||
Expect(w.Code).To(Equal(http.StatusNoContent))
|
||||
Expect(repo.Queue).ToNot(BeNil())
|
||||
Expect(repo.Queue.Items).To(HaveLen(0))
|
||||
})
|
||||
|
||||
It("returns bad request for invalid current index (negative)", func() {
|
||||
payload := queuePayload{Ids: []string{"s1", "s2"}, Current: -1, Position: 10}
|
||||
body, _ := json.Marshal(payload)
|
||||
req := httptest.NewRequest("POST", "/queue", bytes.NewReader(body))
|
||||
req = req.WithContext(request.WithUser(req.Context(), user))
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
saveQueue(ds)(w, req)
|
||||
Expect(w.Code).To(Equal(http.StatusBadRequest))
|
||||
Expect(w.Body.String()).To(ContainSubstring("current index out of bounds"))
|
||||
})
|
||||
|
||||
It("returns bad request for invalid current index (too large)", func() {
|
||||
payload := queuePayload{Ids: []string{"s1", "s2"}, Current: 2, Position: 10}
|
||||
body, _ := json.Marshal(payload)
|
||||
req := httptest.NewRequest("POST", "/queue", bytes.NewReader(body))
|
||||
req = req.WithContext(request.WithUser(req.Context(), user))
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
saveQueue(ds)(w, req)
|
||||
Expect(w.Code).To(Equal(http.StatusBadRequest))
|
||||
Expect(w.Body.String()).To(ContainSubstring("current index out of bounds"))
|
||||
})
|
||||
|
||||
It("returns bad request for malformed JSON", func() {
|
||||
req := httptest.NewRequest("POST", "/queue", bytes.NewReader([]byte("invalid json")))
|
||||
req = req.WithContext(request.WithUser(req.Context(), user))
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
saveQueue(ds)(w, req)
|
||||
Expect(w.Code).To(Equal(http.StatusBadRequest))
|
||||
})
|
||||
|
||||
It("returns internal server error when store fails", func() {
|
||||
repo.Err = true
|
||||
payload := queuePayload{Ids: []string{"s1"}, Current: 0, Position: 10}
|
||||
body, _ := json.Marshal(payload)
|
||||
req := httptest.NewRequest("POST", "/queue", bytes.NewReader(body))
|
||||
req = req.WithContext(request.WithUser(req.Context(), user))
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
saveQueue(ds)(w, req)
|
||||
Expect(w.Code).To(Equal(http.StatusInternalServerError))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("GET /queue", func() {
|
||||
It("returns the queue", func() {
|
||||
queue := &model.PlayQueue{
|
||||
UserID: user.ID,
|
||||
Current: 1,
|
||||
Position: 55,
|
||||
Items: model.MediaFiles{
|
||||
{ID: "track1", Title: "Song 1"},
|
||||
{ID: "track2", Title: "Song 2"},
|
||||
{ID: "track3", Title: "Song 3"},
|
||||
},
|
||||
}
|
||||
repo.Queue = queue
|
||||
req := httptest.NewRequest("GET", "/queue", nil)
|
||||
req = req.WithContext(request.WithUser(req.Context(), user))
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
getQueue(ds)(w, req)
|
||||
Expect(w.Code).To(Equal(http.StatusOK))
|
||||
Expect(w.Header().Get("Content-Type")).To(Equal("application/json"))
|
||||
var resp model.PlayQueue
|
||||
Expect(json.Unmarshal(w.Body.Bytes(), &resp)).To(Succeed())
|
||||
Expect(resp.Current).To(Equal(1))
|
||||
Expect(resp.Position).To(Equal(int64(55)))
|
||||
Expect(resp.Items).To(HaveLen(3))
|
||||
Expect(resp.Items[0].ID).To(Equal("track1"))
|
||||
Expect(resp.Items[1].ID).To(Equal("track2"))
|
||||
Expect(resp.Items[2].ID).To(Equal("track3"))
|
||||
})
|
||||
|
||||
It("returns empty queue when user has no queue", func() {
|
||||
req := httptest.NewRequest("GET", "/queue", nil)
|
||||
req = req.WithContext(request.WithUser(req.Context(), user))
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
getQueue(ds)(w, req)
|
||||
Expect(w.Code).To(Equal(http.StatusOK))
|
||||
var resp model.PlayQueue
|
||||
Expect(json.Unmarshal(w.Body.Bytes(), &resp)).To(Succeed())
|
||||
Expect(resp.Items).To(BeEmpty())
|
||||
Expect(resp.Current).To(Equal(0))
|
||||
Expect(resp.Position).To(Equal(int64(0)))
|
||||
})
|
||||
|
||||
It("returns internal server error when retrieve fails", func() {
|
||||
repo.Err = true
|
||||
req := httptest.NewRequest("GET", "/queue", nil)
|
||||
req = req.WithContext(request.WithUser(req.Context(), user))
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
getQueue(ds)(w, req)
|
||||
Expect(w.Code).To(Equal(http.StatusInternalServerError))
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -82,9 +82,13 @@ func (api *Router) GetPlayQueue(r *http.Request) (*responses.Subsonic, error) {
|
||||
}
|
||||
|
||||
response := newResponse()
|
||||
var currentID string
|
||||
if pq.Current >= 0 && pq.Current < len(pq.Items) {
|
||||
currentID = pq.Items[pq.Current].ID
|
||||
}
|
||||
response.PlayQueue = &responses.PlayQueue{
|
||||
Entry: slice.MapWithArg(pq.Items, r.Context(), childFromMediaFile),
|
||||
Current: pq.Current,
|
||||
Current: currentID,
|
||||
Position: pq.Position,
|
||||
Username: user.UserName,
|
||||
Changed: &pq.UpdatedAt,
|
||||
@@ -96,20 +100,27 @@ func (api *Router) GetPlayQueue(r *http.Request) (*responses.Subsonic, error) {
|
||||
func (api *Router) SavePlayQueue(r *http.Request) (*responses.Subsonic, error) {
|
||||
p := req.Params(r)
|
||||
ids, _ := p.Strings("id")
|
||||
current, _ := p.String("current")
|
||||
currentID, _ := p.String("current")
|
||||
position := p.Int64Or("position", 0)
|
||||
|
||||
user, _ := request.UserFrom(r.Context())
|
||||
client, _ := request.ClientFrom(r.Context())
|
||||
|
||||
var items model.MediaFiles
|
||||
for _, id := range ids {
|
||||
items = append(items, model.MediaFile{ID: id})
|
||||
items := slice.Map(ids, func(id string) model.MediaFile {
|
||||
return model.MediaFile{ID: id}
|
||||
})
|
||||
|
||||
currentIndex := 0
|
||||
for i, id := range ids {
|
||||
if id == currentID {
|
||||
currentIndex = i
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
pq := &model.PlayQueue{
|
||||
UserID: user.ID,
|
||||
Current: current,
|
||||
Current: currentIndex,
|
||||
Position: position,
|
||||
ChangedBy: client,
|
||||
Items: items,
|
||||
|
||||
Reference in New Issue
Block a user