diff --git a/db/migration/20200731095603_create_play_queues_table.go b/db/migration/20200731095603_create_play_queues_table.go new file mode 100644 index 00000000000..7c5956d8c9c --- /dev/null +++ b/db/migration/20200731095603_create_play_queues_table.go @@ -0,0 +1,36 @@ +package migration + +import ( + "database/sql" + + "github.com/pressly/goose" +) + +func init() { + goose.AddMigration(upCreatePlayQueuesTable, downCreatePlayQueuesTable) +} + +func upCreatePlayQueuesTable(tx *sql.Tx) error { + _, err := tx.Exec(` +create table playqueue +( + id varchar(255) not null, + user_id varchar(255) not null + references user (id) + on update cascade on delete cascade, + comment varchar(255), + current varchar(255), + position real, + changed_by varchar(255), + items varchar(255), + created_at datetime, + updated_at datetime +); +`) + + return err +} + +func downCreatePlayQueuesTable(tx *sql.Tx) error { + return nil +} diff --git a/model/playqueue.go b/model/playqueue.go new file mode 100644 index 00000000000..2c482807eaa --- /dev/null +++ b/model/playqueue.go @@ -0,0 +1,24 @@ +package model + +import ( + "time" +) + +type PlayQueue struct { + ID string `json:"id" orm:"column(id)"` + UserID string `json:"userId" orm:"column(user_id)"` + Comment string `json:"comment"` + Current string `json:"current"` + Position float32 `json:"position"` + ChangedBy string `json:"changedBy"` + Items MediaFiles `json:"items,omitempty"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` +} + +type PlayQueues []PlayQueue + +type PlayQueueRepository interface { + Store(queue *PlayQueue) error + Retrieve(userId string) (*PlayQueue, error) +} diff --git a/persistence/playqueue_repository.go b/persistence/playqueue_repository.go new file mode 100644 index 00000000000..cbd67a8dde1 --- /dev/null +++ b/persistence/playqueue_repository.go @@ -0,0 +1,151 @@ +package persistence + +import ( + "context" + "strings" + "time" + + . "github.com/Masterminds/squirrel" + "github.com/astaxie/beego/orm" + "github.com/deluan/navidrome/log" + "github.com/deluan/navidrome/model" +) + +type playQueueRepository struct { + sqlRepository + sqlRestful +} + +func NewPlayQueueRepository(ctx context.Context, o orm.Ormer) model.PlayQueueRepository { + r := &playQueueRepository{} + r.ctx = ctx + r.ormer = o + r.tableName = "playqueue" + return r +} + +type playQueue struct { + ID string `orm:"column(id)"` + UserID string `orm:"column(user_id)"` + Comment string + Current string + Position float32 + ChangedBy string + Items string + CreatedAt time.Time + UpdatedAt time.Time +} + +func (r *playQueueRepository) Store(q *model.PlayQueue) error { + err := r.clearPlayQueue(q.UserID) + if err != nil { + return err + } + pq := r.fromModel(q) + if pq.ID == "" { + pq.CreatedAt = time.Now() + } + pq.UpdatedAt = time.Now() + _, err = r.put(pq.ID, pq) + return err +} + +func (r *playQueueRepository) Retrieve(userId string) (*model.PlayQueue, error) { + sel := r.newSelect().Columns("*").Where(Eq{"user_id": userId}) + var res playQueue + err := r.queryOne(sel, &res) + pls := r.toModel(&res) + return &pls, err + +} + +func (r *playQueueRepository) fromModel(q *model.PlayQueue) playQueue { + pq := playQueue{ + ID: q.ID, + UserID: q.UserID, + Comment: q.Comment, + Current: q.Current, + Position: q.Position, + ChangedBy: q.ChangedBy, + CreatedAt: q.CreatedAt, + UpdatedAt: q.UpdatedAt, + } + var itemIDs []string + for _, t := range q.Items { + itemIDs = append(itemIDs, t.ID) + } + pq.Items = strings.Join(itemIDs, ",") + return pq +} + +func (r *playQueueRepository) toModel(pq *playQueue) model.PlayQueue { + q := model.PlayQueue{ + ID: pq.ID, + UserID: pq.UserID, + Comment: pq.Comment, + Current: pq.Current, + Position: pq.Position, + ChangedBy: pq.ChangedBy, + CreatedAt: pq.CreatedAt, + UpdatedAt: pq.UpdatedAt, + } + if strings.TrimSpace(pq.Items) != "" { + tracks := strings.Split(pq.Items, ",") + for _, t := range tracks { + q.Items = append(q.Items, model.MediaFile{ID: t}) + } + } + q.Items = r.loadTracks(&q) + return q +} + +func (r *playQueueRepository) loadTracks(p *model.PlayQueue) model.MediaFiles { + if len(p.Items) == 0 { + return nil + } + + // Collect all ids + ids := make([]string, len(p.Items)) + for i, t := range p.Items { + ids[i] = t.ID + } + + // Break the list in chunks, up to 50 items, to avoid hitting SQLITE_MAX_FUNCTION_ARG limit + const chunkSize = 50 + var chunks [][]string + for i := 0; i < len(ids); i += chunkSize { + end := i + chunkSize + if end > len(ids) { + end = len(ids) + } + + chunks = append(chunks, ids[i:end]) + } + + // Query each chunk of media_file ids and store results in a map + mfRepo := NewMediaFileRepository(r.ctx, r.ormer) + trackMap := map[string]model.MediaFile{} + for i := range chunks { + idsFilter := Eq{"id": chunks[i]} + tracks, err := mfRepo.GetAll(model.QueryOptions{Filters: idsFilter}) + if err != nil { + log.Error(r.ctx, "Could not load playqueue's tracks", "userId", p.UserID, err) + } + for _, t := range tracks { + trackMap[t.ID] = t + } + } + + // Create a new list of tracks with the same order as the original + newTracks := make(model.MediaFiles, len(p.Items)) + for i, t := range p.Items { + newTracks[i] = trackMap[t.ID] + } + return newTracks +} + +func (r *playQueueRepository) clearPlayQueue(userId string) error { + return r.delete(Eq{"user_id": userId}) +} + +var _ model.PlayQueueRepository = (*playQueueRepository)(nil) diff --git a/persistence/playqueue_repository_test.go b/persistence/playqueue_repository_test.go new file mode 100644 index 00000000000..6fae30c942f --- /dev/null +++ b/persistence/playqueue_repository_test.go @@ -0,0 +1,92 @@ +package persistence + +import ( + "context" + "time" + + "github.com/Masterminds/squirrel" + "github.com/astaxie/beego/orm" + "github.com/deluan/navidrome/log" + "github.com/deluan/navidrome/model" + "github.com/deluan/navidrome/model/request" + "github.com/google/uuid" + . "github.com/onsi/ginkgo" + . "github.com/onsi/gomega" +) + +var _ = Describe("PlayQueueRepository", func() { + var repo model.PlayQueueRepository + + BeforeEach(func() { + ctx := log.NewContext(context.TODO()) + ctx = request.WithUser(ctx, model.User{ID: "user1", UserName: "user1", IsAdmin: true}) + repo = NewPlayQueueRepository(ctx, orm.NewOrm()) + }) + + It("returns notfound error if there's no playqueue for the user", func() { + _, err := repo.Retrieve("user999") + Expect(err).To(MatchError(model.ErrNotFound)) + }) + + It("stores and retrieves the playqueue for the user", func() { + By("Storing a playqueue for the user") + + expected := aPlayQueue("user1", songDayInALife.ID, 123, songComeTogether, songDayInALife) + Expect(repo.Store(expected)).To(BeNil()) + + actual, err := repo.Retrieve("user1") + Expect(err).To(BeNil()) + + AssertPlayQueue(expected, actual) + + By("Storing a new playqueue for the same user") + + new := aPlayQueue("user1", songRadioactivity.ID, 321, songAntenna, songRadioactivity) + Expect(repo.Store(new)).To(BeNil()) + + actual, err = repo.Retrieve("user1") + Expect(err).To(BeNil()) + + AssertPlayQueue(new, actual) + Expect(countPlayQueues(repo, "user1")).To(Equal(1)) + }) +}) + +func countPlayQueues(repo model.PlayQueueRepository, userId string) int { + r := repo.(*playQueueRepository) + c, err := r.count(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.Comment).To(Equal(expected.Comment)) + 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, current string, position float32, items ...model.MediaFile) *model.PlayQueue { + createdAt := time.Now() + updatedAt := createdAt.Add(time.Minute) + id, _ := uuid.NewRandom() + return &model.PlayQueue{ + ID: id.String(), + UserID: userId, + Comment: "no_comments", + Current: current, + Position: position, + ChangedBy: "test", + Items: items, + CreatedAt: createdAt, + UpdatedAt: updatedAt, + } +}