diff --git a/db/migrations/20250611010101_playqueue_current_to_index.go b/db/migrations/20250611010101_playqueue_current_to_index.go new file mode 100644 index 000000000..d9250eba2 --- /dev/null +++ b/db/migrations/20250611010101_playqueue_current_to_index.go @@ -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, ¤tID, &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 +} diff --git a/model/playqueue.go b/model/playqueue.go index 52ba173d3..6b666b188 100644 --- a/model/playqueue.go +++ b/model/playqueue.go @@ -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"` diff --git a/persistence/playqueue_repository.go b/persistence/playqueue_repository.go index fe42dd7fc..a74c31e38 100644 --- a/persistence/playqueue_repository.go +++ b/persistence/playqueue_repository.go @@ -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"` diff --git a/persistence/playqueue_repository_test.go b/persistence/playqueue_repository_test.go index a370e1162..dcf2c99ca 100644 --- a/persistence/playqueue_repository_test.go +++ b/persistence/playqueue_repository_test.go @@ -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{ diff --git a/server/nativeapi/native_api.go b/server/nativeapi/native_api.go index 3586a86a0..5f9013d6d 100644 --- a/server/nativeapi/native_api.go +++ b/server/nativeapi/native_api.go @@ -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) diff --git a/server/nativeapi/queue.go b/server/nativeapi/queue.go new file mode 100644 index 000000000..e9a5c6e51 --- /dev/null +++ b/server/nativeapi/queue.go @@ -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) + } +} diff --git a/server/nativeapi/queue_test.go b/server/nativeapi/queue_test.go new file mode 100644 index 000000000..64f2e066b --- /dev/null +++ b/server/nativeapi/queue_test.go @@ -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)) + }) + }) +}) diff --git a/server/subsonic/bookmarks.go b/server/subsonic/bookmarks.go index f6fd1a99e..2316e474a 100644 --- a/server/subsonic/bookmarks.go +++ b/server/subsonic/bookmarks.go @@ -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, diff --git a/tests/mock_data_store.go b/tests/mock_data_store.go index fb5bbd710..b146a3b56 100644 --- a/tests/mock_data_store.go +++ b/tests/mock_data_store.go @@ -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 { diff --git a/tests/mock_playqueue_repo.go b/tests/mock_playqueue_repo.go new file mode 100644 index 000000000..4812e0667 --- /dev/null +++ b/tests/mock_playqueue_repo.go @@ -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 +}