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:
Deluan Quintão
2025-06-10 23:00:44 -04:00
committed by GitHub
parent 76042ba173
commit 8fcd8ba61a
10 changed files with 398 additions and 15 deletions

View File

@@ -0,0 +1,80 @@
package migrations
import (
"context"
"database/sql"
"strings"
"github.com/pressly/goose/v3"
)
func init() {
goose.AddMigrationContext(upPlayQueueCurrentToIndex, downPlayQueueCurrentToIndex)
}
func upPlayQueueCurrentToIndex(ctx context.Context, tx *sql.Tx) error {
_, err := tx.ExecContext(ctx, `
create table playqueue_dg_tmp(
id varchar(255) not null,
user_id varchar(255) not null
references user(id)
on update cascade on delete cascade,
current integer not null default 0,
position real,
changed_by varchar(255),
items varchar(255),
created_at datetime,
updated_at datetime
);`)
if err != nil {
return err
}
rows, err := tx.QueryContext(ctx, `select id, user_id, current, position, changed_by, items, created_at, updated_at from playqueue`)
if err != nil {
return err
}
defer rows.Close()
stmt, err := tx.PrepareContext(ctx, `insert into playqueue_dg_tmp(id, user_id, current, position, changed_by, items, created_at, updated_at) values(?,?,?,?,?,?,?,?)`)
if err != nil {
return err
}
defer stmt.Close()
for rows.Next() {
var id, userID, currentID, changedBy, items string
var position sql.NullFloat64
var createdAt, updatedAt sql.NullString
if err = rows.Scan(&id, &userID, &currentID, &position, &changedBy, &items, &createdAt, &updatedAt); err != nil {
return err
}
index := 0
if currentID != "" && items != "" {
parts := strings.Split(items, ",")
for i, p := range parts {
if p == currentID {
index = i
break
}
}
}
_, err = stmt.Exec(id, userID, index, position, changedBy, items, createdAt, updatedAt)
if err != nil {
return err
}
}
if err = rows.Err(); err != nil {
return err
}
if _, err = tx.ExecContext(ctx, `drop table playqueue;`); err != nil {
return err
}
_, err = tx.ExecContext(ctx, `alter table playqueue_dg_tmp rename to playqueue;`)
return err
}
func downPlayQueueCurrentToIndex(ctx context.Context, tx *sql.Tx) error {
return nil
}

View File

@@ -7,7 +7,7 @@ import (
type PlayQueue struct {
ID string `structs:"id" json:"id"`
UserID string `structs:"user_id" json:"userId"`
Current string `structs:"current" json:"current"`
Current int `structs:"current" json:"current"`
Position int64 `structs:"position" json:"position"`
ChangedBy string `structs:"changed_by" json:"changedBy"`
Items MediaFiles `structs:"-" json:"items,omitempty"`

View File

@@ -27,7 +27,7 @@ func NewPlayQueueRepository(ctx context.Context, db dbx.Builder) model.PlayQueue
type playQueue struct {
ID string `structs:"id"`
UserID string `structs:"user_id"`
Current string `structs:"current"`
Current int `structs:"current"`
Position int64 `structs:"position"`
ChangedBy string `structs:"changed_by"`
Items string `structs:"items"`

View File

@@ -32,7 +32,7 @@ var _ = Describe("PlayQueueRepository", func() {
It("stores and retrieves the playqueue for the user", func() {
By("Storing a playqueue for the user")
expected := aPlayQueue("userid", songDayInALife.ID, 123, songComeTogether, songDayInALife)
expected := aPlayQueue("userid", 1, 123, songComeTogether, songDayInALife)
Expect(repo.Store(expected)).To(Succeed())
actual, err := repo.Retrieve("userid")
@@ -42,7 +42,7 @@ var _ = Describe("PlayQueueRepository", func() {
By("Storing a new playqueue for the same user")
another := aPlayQueue("userid", songRadioactivity.ID, 321, songAntenna, songRadioactivity)
another := aPlayQueue("userid", 1, 321, songAntenna, songRadioactivity)
Expect(repo.Store(another)).To(Succeed())
actual, err = repo.Retrieve("userid")
@@ -62,7 +62,7 @@ var _ = Describe("PlayQueueRepository", func() {
Expect(mfRepo.Put(&newSong)).To(Succeed())
// Create a playqueue with the new song
pq := aPlayQueue("userid", newSong.ID, 0, newSong, songAntenna)
pq := aPlayQueue("userid", 0, 0, newSong, songAntenna)
Expect(repo.Store(pq)).To(Succeed())
// Retrieve the playqueue
@@ -107,7 +107,7 @@ func AssertPlayQueue(expected, actual *model.PlayQueue) {
}
}
func aPlayQueue(userId, current string, position int64, items ...model.MediaFile) *model.PlayQueue {
func aPlayQueue(userId string, current int, position int64, items ...model.MediaFile) *model.PlayQueue {
createdAt := time.Now()
updatedAt := createdAt.Add(time.Minute)
return &model.PlayQueue{

View File

@@ -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
View 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)
}
}

View 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))
})
})
})

View File

@@ -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,

View File

@@ -19,6 +19,7 @@ type MockDataStore struct {
MockedProperty model.PropertyRepository
MockedPlayer model.PlayerRepository
MockedPlaylist model.PlaylistRepository
MockedPlayQueue model.PlayQueueRepository
MockedShare model.ShareRepository
MockedTranscoding model.TranscodingRepository
MockedUserProps model.UserPropsRepository
@@ -115,10 +116,14 @@ func (db *MockDataStore) Playlist(ctx context.Context) model.PlaylistRepository
}
func (db *MockDataStore) PlayQueue(ctx context.Context) model.PlayQueueRepository {
if db.RealDS != nil {
return db.RealDS.PlayQueue(ctx)
if db.MockedPlayQueue == nil {
if db.RealDS != nil {
db.MockedPlayQueue = db.RealDS.PlayQueue(ctx)
} else {
db.MockedPlayQueue = &MockPlayQueueRepo{}
}
}
return struct{ model.PlayQueueRepository }{}
return db.MockedPlayQueue
}
func (db *MockDataStore) UserProps(ctx context.Context) model.UserPropsRepository {

View File

@@ -0,0 +1,39 @@
package tests
import (
"errors"
"github.com/navidrome/navidrome/model"
)
type MockPlayQueueRepo struct {
model.PlayQueueRepository
Queue *model.PlayQueue
Err bool
}
func (m *MockPlayQueueRepo) Store(q *model.PlayQueue) error {
if m.Err {
return errors.New("error")
}
copyItems := make(model.MediaFiles, len(q.Items))
copy(copyItems, q.Items)
qCopy := *q
qCopy.Items = copyItems
m.Queue = &qCopy
return nil
}
func (m *MockPlayQueueRepo) Retrieve(userId string) (*model.PlayQueue, error) {
if m.Err {
return nil, errors.New("error")
}
if m.Queue == nil || m.Queue.UserID != userId {
return nil, model.ErrNotFound
}
copyItems := make(model.MediaFiles, len(m.Queue.Items))
copy(copyItems, m.Queue.Items)
qCopy := *m.Queue
qCopy.Items = copyItems
return &qCopy, nil
}