navidrome/persistence/playqueue_repository_test.go
Deluan 4fd394bead fix(playqueue): keep the current track when earlier tracks are missing
RetrieveWithMediaFiles drops queue entries whose tracks are no longer in
the library (purged, or in a library the user lost access to), but it kept
the stored current index. That index then pointed one track further for
each dropped track before it, so getPlayQueue, getPlayQueueByIndex and
GET /api/queue reported the wrong song with the old position.

The current index is now remapped to the same track in the filtered list.
If the current track itself is gone, the queue moves to the next remaining
track (or the last one) and restarts it. The cleaned queue is then saved
back in the background, so partial PUT /api/queue updates use the same
list the client sees. The save is a conditional UPDATE that only applies
if the row is unchanged since it was read, so it never undoes a newer
client save, and it is skipped if loading the tracks failed.

Fixes #6297
2026-10-08 20:52:06 -04:00

468 lines
17 KiB
Go

package persistence
import (
"context"
"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"
"github.com/navidrome/navidrome/model/request"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("PlayQueueRepository", func() {
var repo model.PlayQueueRepository
var ctx context.Context
BeforeEach(func() {
DeferCleanup(configtest.SetupConfig())
ctx = log.NewContext(GinkgoT().Context())
ctx = request.WithUser(ctx, model.User{ID: "userid", UserName: "userid", IsAdmin: true})
repo = NewPlayQueueRepository(GetDBXBuilder())
})
Describe("Store", func() {
It("stores a complete playqueue", func() {
expected := aPlayQueue("userid", 1, 123, songComeTogether, songDayInALife)
Expect(repo.Store(ctx, expected)).To(Succeed())
actual, err := repo.RetrieveWithMediaFiles(ctx, "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(ctx, initial)).To(Succeed())
By("Storing replacement playqueue")
replacement := aPlayQueue("userid", 1, 200, songDayInALife, songAntenna)
Expect(repo.Store(ctx, replacement)).To(Succeed())
actual, err := repo.RetrieveWithMediaFiles(ctx, "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(ctx, initial)).To(Succeed())
By("Storing empty playqueue")
empty := aPlayQueue("userid", 0, 0)
Expect(repo.Store(ctx, empty)).To(Succeed())
By("Verifying playqueue is cleared")
_, err := repo.Retrieve(ctx, "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(ctx, initial)).To(Succeed())
By("Getting the existing playqueue to obtain its ID")
existing, err := repo.Retrieve(ctx, "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(ctx, update, "current")).To(Succeed())
By("Verifying only current was updated")
actual, err := repo.Retrieve(ctx, "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(ctx, initial)).To(Succeed())
By("Getting the existing playqueue to obtain its ID")
existing, err := repo.Retrieve(ctx, "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(ctx, update, "position")).To(Succeed())
By("Verifying only position was updated")
actual, err := repo.Retrieve(ctx, "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(ctx, initial)).To(Succeed())
By("Getting the existing playqueue to obtain its ID")
existing, err := repo.Retrieve(ctx, "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(ctx, update, "current", "position")).To(Succeed())
By("Verifying both fields were updated")
actual, err := repo.Retrieve(ctx, "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(ctx, initial)).To(Succeed())
By("Getting the existing playqueue to obtain its ID")
existing, err := repo.Retrieve(ctx, "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(ctx, update, "position")).To(Succeed())
By("Verifying items are preserved")
actual, err := repo.Retrieve(ctx, "userid")
Expect(err).ToNot(HaveOccurred())
Expect(actual.Position).To(Equal(int64(200)))
Expect(actual.Items).To(HaveLen(2)) // Should remain unchanged
})
It("ensures only one record per user by reusing existing record ID", func() {
By("Storing initial playqueue")
initial := aPlayQueue("userid", 0, 100, songComeTogether)
Expect(repo.Store(ctx, initial)).To(Succeed())
initialCount := countPlayQueues(repo, "userid")
Expect(initialCount).To(Equal(1))
By("Storing another playqueue with different ID but same user")
different := aPlayQueue("userid", 1, 200, songDayInALife)
different.ID = "different-id" // Force a different ID
Expect(repo.Store(ctx, different)).To(Succeed())
By("Verifying only one record exists for the user")
finalCount := countPlayQueues(repo, "userid")
Expect(finalCount).To(Equal(1))
By("Verifying the record was updated, not duplicated")
actual, err := repo.Retrieve(ctx, "userid")
Expect(err).ToNot(HaveOccurred())
Expect(actual.Current).To(Equal(1)) // Should be updated value
Expect(actual.Position).To(Equal(int64(200))) // Should be updated value
Expect(actual.Items).To(HaveLen(1)) // Should be new items
Expect(actual.Items[0].ID).To(Equal(songDayInALife.ID))
})
It("ensures only one record per user even with partial updates", func() {
By("Storing initial playqueue")
initial := aPlayQueue("userid", 0, 100, songComeTogether, songDayInALife)
Expect(repo.Store(ctx, initial)).To(Succeed())
initialCount := countPlayQueues(repo, "userid")
Expect(initialCount).To(Equal(1))
By("Storing partial update with different ID but same user")
partialUpdate := &model.PlayQueue{
ID: "completely-different-id", // Use a completely different ID
UserID: "userid",
Current: 1,
ChangedBy: "test-partial",
}
Expect(repo.Store(ctx, partialUpdate, "current")).To(Succeed())
By("Verifying only one record still exists for the user")
finalCount := countPlayQueues(repo, "userid")
Expect(finalCount).To(Equal(1))
By("Verifying the existing record was updated with new current value")
actual, err := repo.Retrieve(ctx, "userid")
Expect(err).ToNot(HaveOccurred())
Expect(actual.Current).To(Equal(1)) // Should be updated value
Expect(actual.Position).To(Equal(int64(100))) // Should remain unchanged
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(ctx, "user999")
Expect(err).To(MatchError(model.ErrNotFound))
})
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)
Expect(repo.Store(ctx, expected)).To(Succeed())
actual, err := repo.Retrieve(ctx, "userid")
Expect(err).ToNot(HaveOccurred())
// 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)))
// 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())
}
})
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(GetDBXBuilder())
Expect(mfRepo.Put(ctx, &newSong)).To(Succeed())
// Create a playqueue with the new song
pq := aPlayQueue("userid", 0, 0, newSong, songAntenna)
Expect(repo.Store(ctx, pq)).To(Succeed())
// Delete the new song from the database
Expect(mfRepo.Delete(ctx, "temp-track")).To(Succeed())
// Retrieve the playqueue with Retrieve method
actual, err := repo.Retrieve(ctx, "userid")
Expect(err).ToNot(HaveOccurred())
// 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(ctx, "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(ctx, expected)).To(Succeed())
actual, err := repo.RetrieveWithMediaFiles(ctx, "userid")
Expect(err).ToNot(HaveOccurred())
AssertPlayQueue(expected, actual)
})
Context("when tracks are no longer in the library", func() {
var mfRepo model.MediaFileRepository
gone := songRadioactivity
gone.ID = "gone-track"
gone.Path = "/gone-track"
BeforeEach(func() {
mfRepo = NewMediaFileRepository(GetDBXBuilder())
Expect(mfRepo.Put(ctx, &gone)).To(Succeed())
DeferCleanup(func() { _ = mfRepo.Delete(ctx, gone.ID) })
})
retrieveAfterRemoval := func(current int, position int64, items ...model.MediaFile) *model.PlayQueue {
Expect(repo.Store(ctx, aPlayQueue("userid", current, position, items...))).To(Succeed())
Expect(mfRepo.Delete(ctx, gone.ID)).To(Succeed())
actual, err := repo.RetrieveWithMediaFiles(ctx, "userid")
Expect(err).ToNot(HaveOccurred())
return actual
}
DescribeTable("points to the right current track",
func(current int, items []model.MediaFile, expectedID string, expectedPosition int64) {
actual := retrieveAfterRemoval(current, 45, items...)
Expect(actual.Items).To(HaveLen(len(items) - 1))
Expect(actual.Items[actual.Current].ID).To(Equal(expectedID))
Expect(actual.Position).To(Equal(expectedPosition))
},
Entry("when an earlier track is removed", 1, []model.MediaFile{gone, songComeTogether, songDayInALife}, songComeTogether.ID, int64(45)),
Entry("when a later track is removed", 0, []model.MediaFile{songComeTogether, gone}, songComeTogether.ID, int64(45)),
Entry("moving to the next track from the start when the current one is removed", 1, []model.MediaFile{songComeTogether, gone, songDayInALife}, songDayInALife.ID, int64(0)),
Entry("moving to the last track when the removed current one was the last", 1, []model.MediaFile{songComeTogether, gone}, songComeTogether.ID, int64(0)),
)
It("saves the cleaned queue", func() {
retrieveAfterRemoval(1, 45, gone, songComeTogether, songDayInALife)
Eventually(func(g Gomega) {
stored, err := repo.Retrieve(ctx, "userid")
g.Expect(err).ToNot(HaveOccurred())
g.Expect(stored.Items).To(HaveLen(2))
g.Expect(stored.Items[0].ID).To(Equal(songComeTogether.ID))
g.Expect(stored.Current).To(BeZero())
g.Expect(stored.Position).To(Equal(int64(45)))
}).Should(Succeed())
})
It("does not overwrite a queue that changed after it was read", func() {
r := repo.(*playQueueRepository)
read := aPlayQueue("userid", 1, 45, gone, songComeTogether)
Expect(repo.Store(ctx, read)).To(Succeed())
By("Saving a new queue from a client before the cleanup runs")
Expect(repo.Store(ctx, aPlayQueue("userid", 0, 10, songAntenna))).To(Succeed())
r.saveCleanedQueue(ctx, r.fromModel(read), playQueue{Items: songComeTogether.ID, Position: 45})
stored, err := repo.Retrieve(ctx, "userid")
Expect(err).ToNot(HaveOccurred())
Expect(stored.Items).To(HaveLen(1))
Expect(stored.Items[0].ID).To(Equal(songAntenna.ID))
Expect(stored.Position).To(Equal(int64(10)))
})
})
})
Describe("Clear", func() {
It("clears an existing playqueue", func() {
By("Storing a playqueue")
expected := aPlayQueue("userid", 1, 123, songComeTogether, songDayInALife)
Expect(repo.Store(ctx, expected)).To(Succeed())
By("Verifying playqueue exists")
_, err := repo.Retrieve(ctx, "userid")
Expect(err).ToNot(HaveOccurred())
By("Clearing the playqueue")
Expect(repo.Clear(ctx, "userid")).To(Succeed())
By("Verifying playqueue is cleared")
_, err = repo.Retrieve(ctx, "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(ctx, "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(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(ctx, user1)).To(Succeed())
Expect(userRepo.Put(ctx, user2)).To(Succeed())
By("Storing playqueues for two users")
user1Queue := aPlayQueue("user1", 0, 100, songComeTogether)
user2Queue := aPlayQueue("user2", 1, 200, songDayInALife)
Expect(repo.Store(ctx, user1Queue)).To(Succeed())
Expect(repo.Store(ctx, user2Queue)).To(Succeed())
By("Clearing only user1's playqueue")
Expect(repo.Clear(ctx, "user1")).To(Succeed())
By("Verifying user1's playqueue is cleared")
_, err := repo.Retrieve(ctx, "user1")
Expect(err).To(MatchError(model.ErrNotFound))
By("Verifying user2's playqueue still exists")
actual, err := repo.Retrieve(ctx, "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 {
r := repo.(*playQueueRepository)
c, err := r.count(GinkgoT().Context(), squirrel.Select().Where(squirrel.Eq{"user_id": userId}))
if err != nil {
panic(err)
}
return int(c)
}
func AssertPlayQueue(expected, actual *model.PlayQueue) {
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)))
for i, item := range actual.Items {
Expect(item.Title).To(Equal(expected.Items[i].Title))
}
}
func aPlayQueue(userId string, current int, position int64, items ...model.MediaFile) *model.PlayQueue {
createdAt := time.Now()
updatedAt := createdAt.Add(time.Minute)
return &model.PlayQueue{
ID: id.NewRandom(),
UserID: userId,
Current: current,
Position: position,
ChangedBy: "test",
Items: items,
CreatedAt: createdAt,
UpdatedAt: updatedAt,
}
}