diff --git a/db/migration/20200411164603_add_created_and_updated_fields_to_playlists.go b/db/migration/20200411164603_add_created_and_updated_fields_to_playlists.go new file mode 100644 index 000000000..19e4326f1 --- /dev/null +++ b/db/migration/20200411164603_add_created_and_updated_fields_to_playlists.go @@ -0,0 +1,26 @@ +package migration + +import ( + "database/sql" + "github.com/pressly/goose" +) + +func init() { + goose.AddMigration(Up20200411164603, Down20200411164603) +} + +func Up20200411164603(tx *sql.Tx) error { + _, err := tx.Exec(` +alter table playlist + add created_at datetime; +alter table playlist + add updated_at datetime; +update playlist + set created_at = datetime('now'), updated_at = datetime('now'); +`) + return err +} + +func Down20200411164603(tx *sql.Tx) error { + return nil +} diff --git a/engine/playlists.go b/engine/playlists.go index e1da7748a..409188d48 100644 --- a/engine/playlists.go +++ b/engine/playlists.go @@ -2,6 +2,7 @@ package engine import ( "context" + "time" "github.com/deluan/navidrome/model" "github.com/deluan/navidrome/utils" @@ -118,6 +119,8 @@ type PlaylistInfo struct { Public bool Owner string Comment string + Created time.Time + Changed time.Time } func (p *playlists) Get(ctx context.Context, id string) (*PlaylistInfo, error) { @@ -135,6 +138,8 @@ func (p *playlists) Get(ctx context.Context, id string) (*PlaylistInfo, error) { Public: pl.Public, Owner: pl.Owner, Comment: pl.Comment, + Changed: pl.UpdatedAt, + Created: pl.CreatedAt, } plsInfo.Entries = FromMediaFiles(pl.Tracks) diff --git a/model/playlist.go b/model/playlist.go index 575cd63f5..456052125 100644 --- a/model/playlist.go +++ b/model/playlist.go @@ -1,13 +1,17 @@ package model +import "time" + type Playlist struct { - ID string - Name string - Comment string - Duration float32 - Owner string - Public bool - Tracks MediaFiles + ID string + Name string + Comment string + Duration float32 + Owner string + Public bool + Tracks MediaFiles + CreatedAt time.Time + UpdatedAt time.Time } type PlaylistRepository interface { diff --git a/persistence/persistence_suite_test.go b/persistence/persistence_suite_test.go index 6b7798ac3..fc2414124 100644 --- a/persistence/persistence_suite_test.go +++ b/persistence/persistence_suite_test.go @@ -65,13 +65,12 @@ var ( var ( plsBest = model.Playlist{ - ID: "10", - Name: "Best", - Comment: "No Comments", - Duration: 10, - Owner: "userid", - Public: true, - Tracks: model.MediaFiles{{ID: "1"}, {ID: "3"}}, + ID: "10", + Name: "Best", + Comment: "No Comments", + Owner: "userid", + Public: true, + Tracks: model.MediaFiles{{ID: "1"}, {ID: "3"}}, } plsCool = model.Playlist{ID: "11", Name: "Cool", Tracks: model.MediaFiles{{ID: "4"}}} testPlaylists = model.Playlists{plsBest, plsCool} diff --git a/persistence/playlist_repository.go b/persistence/playlist_repository.go index 06eb7824e..8a7f1d68d 100644 --- a/persistence/playlist_repository.go +++ b/persistence/playlist_repository.go @@ -3,20 +3,24 @@ 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 playlist struct { - ID string `orm:"column(id)"` - Name string - Comment string - Duration float32 - Owner string - Public bool - Tracks string + ID string `orm:"column(id)"` + Name string + Comment string + Duration float32 + Owner string + Public bool + Tracks string + CreatedAt time.Time + UpdatedAt time.Time } type playlistRepository struct { @@ -44,6 +48,10 @@ func (r *playlistRepository) Delete(id string) error { } func (r *playlistRepository) Put(p *model.Playlist) error { + if p.ID == "" { + p.CreatedAt = time.Now() + } + p.UpdatedAt = time.Now() pls := r.fromModel(p) _, err := r.put(pls.ID, pls) return err @@ -62,18 +70,11 @@ func (r *playlistRepository) GetWithTracks(id string) (*model.Playlist, error) { if err != nil { return nil, err } - mfRepo := NewMediaFileRepository(r.ctx, r.ormer) pls.Duration = 0 - newTracks := model.MediaFiles{} + pls.Tracks = r.loadTracks(pls) for _, t := range pls.Tracks { - mf, err := mfRepo.Get(t.ID) - if err != nil { - continue - } - pls.Duration += mf.Duration - newTracks = append(newTracks, *mf) + pls.Duration += t.Duration } - pls.Tracks = newTracks return pls, err } @@ -94,12 +95,14 @@ func (r *playlistRepository) toModels(all []playlist) model.Playlists { func (r *playlistRepository) toModel(p *playlist) model.Playlist { pls := model.Playlist{ - ID: p.ID, - Name: p.Name, - Comment: p.Comment, - Duration: p.Duration, - Owner: p.Owner, - Public: p.Public, + ID: p.ID, + Name: p.Name, + Comment: p.Comment, + Duration: p.Duration, + Owner: p.Owner, + Public: p.Public, + CreatedAt: p.CreatedAt, + UpdatedAt: p.UpdatedAt, } if strings.TrimSpace(p.Tracks) != "" { tracks := strings.Split(p.Tracks, ",") @@ -107,24 +110,44 @@ func (r *playlistRepository) toModel(p *playlist) model.Playlist { pls.Tracks = append(pls.Tracks, model.MediaFile{ID: t}) } } + pls.Tracks = r.loadTracks(&pls) return pls } func (r *playlistRepository) fromModel(p *model.Playlist) playlist { pls := playlist{ - ID: p.ID, - Name: p.Name, - Comment: p.Comment, - Duration: p.Duration, - Owner: p.Owner, - Public: p.Public, + ID: p.ID, + Name: p.Name, + Comment: p.Comment, + Owner: p.Owner, + Public: p.Public, + CreatedAt: p.CreatedAt, + UpdatedAt: p.UpdatedAt, } + p.Tracks = r.loadTracks(p) var newTracks []string for _, t := range p.Tracks { newTracks = append(newTracks, t.ID) + pls.Duration += t.Duration } pls.Tracks = strings.Join(newTracks, ",") return pls } +func (r *playlistRepository) loadTracks(p *model.Playlist) model.MediaFiles { + mfRepo := NewMediaFileRepository(r.ctx, r.ormer) + var ids []string + for _, t := range p.Tracks { + ids = append(ids, t.ID) + } + idsFilter := Eq{"id": ids} + tracks, err := mfRepo.GetAll(model.QueryOptions{Filters: idsFilter}) + if err == nil { + return tracks + } else { + log.Error(r.ctx, "Could not load playlist's tracks", "playlistName", p.Name, "playlistId", p.ID, err) + } + return nil +} + var _ model.PlaylistRepository = (*playlistRepository)(nil) diff --git a/persistence/playlist_repository_test.go b/persistence/playlist_repository_test.go index 450553efb..bfbbe7adf 100644 --- a/persistence/playlist_repository_test.go +++ b/persistence/playlist_repository_test.go @@ -32,7 +32,18 @@ var _ = Describe("PlaylistRepository", func() { Describe("Get", func() { It("returns an existing playlist", func() { - Expect(repo.Get("10")).To(Equal(&plsBest)) + p, err := repo.Get("10") + Expect(err).To(BeNil()) + // Compare all but Tracks and timestamps + p2 := *p + p2.Tracks = plsBest.Tracks + p2.UpdatedAt = plsBest.UpdatedAt + p2.CreatedAt = plsBest.CreatedAt + Expect(p2).To(Equal(plsBest)) + // Compare tracks + for i := range p.Tracks { + Expect(p.Tracks[i].ID).To(Equal(plsBest.Tracks[i].ID)) + } }) It("returns ErrNotFound for a non-existing playlist", func() { _, err := repo.Get("666") @@ -40,20 +51,22 @@ var _ = Describe("PlaylistRepository", func() { }) }) - Describe("Put/Get/Delete", func() { - newPls := model.Playlist{ID: "22", Name: "Great!", Tracks: model.MediaFiles{{ID: "4"}}} + Describe("Put/Exists/Delete", func() { + var newPls model.Playlist + BeforeEach(func() { + newPls = model.Playlist{ID: "22", Name: "Great!", Tracks: model.MediaFiles{{ID: "4"}}} + }) It("saves the playlist to the DB", func() { Expect(repo.Put(&newPls)).To(BeNil()) }) It("returns the newly created playlist", func() { - Expect(repo.Get("22")).To(Equal(&newPls)) + Expect(repo.Exists("22")).To(BeTrue()) }) It("returns deletes the playlist", func() { Expect(repo.Delete("22")).To(BeNil()) }) It("returns error if tries to retrieve the deleted playlist", func() { - _, err := repo.Get("22") - Expect(err).To(MatchError(model.ErrNotFound)) + Expect(repo.Exists("22")).To(BeFalse()) }) }) @@ -71,7 +84,10 @@ var _ = Describe("PlaylistRepository", func() { Describe("GetAll", func() { It("returns all playlists from DB", func() { - Expect(repo.GetAll()).To(Equal(model.Playlists{plsBest, plsCool})) + all, err := repo.GetAll() + Expect(err).To(BeNil()) + Expect(all[0].ID).To(Equal(plsBest.ID)) + Expect(all[1].ID).To(Equal(plsCool.ID)) }) }) }) diff --git a/persistence/sql_base_repository.go b/persistence/sql_base_repository.go index ba1881692..1d8eaf81b 100644 --- a/persistence/sql_base_repository.go +++ b/persistence/sql_base_repository.go @@ -160,6 +160,7 @@ func (r sqlRepository) count(countQuery SelectBuilder, options ...model.QueryOpt func (r sqlRepository) put(id string, m interface{}) (newId string, err error) { values, _ := toSqlArgs(m) + // Remove created_at from args and save it for later, if needed fo insert createdAt := values["created_at"] delete(values, "created_at") if id != "" { @@ -178,6 +179,7 @@ func (r sqlRepository) put(id string, m interface{}) (newId string, err error) { id = rand.String() values["id"] = id } + // It is a insert, if there was a created_at, add it back to args if createdAt != nil { values["created_at"] = createdAt } diff --git a/server/subsonic/playlists.go b/server/subsonic/playlists.go index dc6a52228..31012ef09 100644 --- a/server/subsonic/playlists.go +++ b/server/subsonic/playlists.go @@ -36,6 +36,8 @@ func (c *PlaylistsController) GetPlaylists(w http.ResponseWriter, r *http.Reques playlists[i].Duration = int(p.Duration) playlists[i].Owner = p.Owner playlists[i].Public = p.Public + playlists[i].Created = &p.CreatedAt + playlists[i].Changed = &p.UpdatedAt } response := NewResponse() response.Playlists = &responses.Playlists{Playlist: playlists} @@ -58,7 +60,7 @@ func (c *PlaylistsController) GetPlaylist(w http.ResponseWriter, r *http.Request } response := NewResponse() - response.Playlist = c.buildPlaylist(r.Context(), pinfo) + response.Playlist = c.buildPlaylistWithSongs(r.Context(), pinfo) return response, nil } @@ -125,15 +127,24 @@ func (c *PlaylistsController) UpdatePlaylist(w http.ResponseWriter, r *http.Requ return NewResponse(), nil } -func (c *PlaylistsController) buildPlaylist(ctx context.Context, d *engine.PlaylistInfo) *responses.PlaylistWithSongs { - pls := &responses.PlaylistWithSongs{} +func (c *PlaylistsController) buildPlaylistWithSongs(ctx context.Context, d *engine.PlaylistInfo) *responses.PlaylistWithSongs { + pls := &responses.PlaylistWithSongs{ + Playlist: *c.buildPlaylist(d), + } + pls.Entry = ToChildren(ctx, d.Entries) + return pls +} + +func (c *PlaylistsController) buildPlaylist(d *engine.PlaylistInfo) *responses.Playlist { + pls := &responses.Playlist{} pls.Id = d.Id pls.Name = d.Name + pls.Comment = d.Comment pls.SongCount = d.SongCount pls.Owner = d.Owner pls.Duration = d.Duration pls.Public = d.Public - - pls.Entry = ToChildren(ctx, d.Entries) + pls.Created = &d.Created + pls.Changed = &d.Changed return pls } diff --git a/server/subsonic/responses/.snapshots/responses-snapshotMatcher-Match-Responses Playlists with data should match .JSON b/server/subsonic/responses/.snapshots/responses-snapshotMatcher-Match-Responses Playlists with data should match .JSON index 5f0a425e9..fa750b471 100644 --- a/server/subsonic/responses/.snapshots/responses-snapshotMatcher-Match-Responses Playlists with data should match .JSON +++ b/server/subsonic/responses/.snapshots/responses-snapshotMatcher-Match-Responses Playlists with data should match .JSON @@ -1 +1 @@ -{"status":"ok","version":"1.8.0","type":"navidrome","serverVersion":"v0.0.0","playlists":{"playlist":[{"id":"111","name":"aaa"},{"id":"222","name":"bbb"}]}} +{"status":"ok","version":"1.8.0","type":"navidrome","serverVersion":"v0.0.0","playlists":{"playlist":[{"id":"111","name":"aaa","comment":"comment","songCount":2,"duration":120,"public":true,"owner":"admin","created":"0001-01-01T00:00:00Z","changed":"0001-01-01T00:00:00Z"},{"id":"222","name":"bbb"}]}} diff --git a/server/subsonic/responses/.snapshots/responses-snapshotMatcher-Match-Responses Playlists with data should match .XML b/server/subsonic/responses/.snapshots/responses-snapshotMatcher-Match-Responses Playlists with data should match .XML index df243dbb7..db890725a 100644 --- a/server/subsonic/responses/.snapshots/responses-snapshotMatcher-Match-Responses Playlists with data should match .XML +++ b/server/subsonic/responses/.snapshots/responses-snapshotMatcher-Match-Responses Playlists with data should match .XML @@ -1 +1 @@ - + diff --git a/server/subsonic/responses/responses.go b/server/subsonic/responses/responses.go index dbfd9576a..bfb6fbcb4 100644 --- a/server/subsonic/responses/responses.go +++ b/server/subsonic/responses/responses.go @@ -188,22 +188,20 @@ type AlbumList struct { } type Playlist struct { - Id string `xml:"id,attr" json:"id"` - Name string `xml:"name,attr" json:"name"` - Comment string `xml:"comment,attr,omitempty" json:"comment,omitempty"` - SongCount int `xml:"songCount,attr,omitempty" json:"songCount,omitempty"` - Duration int `xml:"duration,attr,omitempty" json:"duration,omitempty"` - Public bool `xml:"public,attr,omitempty" json:"public,omitempty"` - Owner string `xml:"owner,attr,omitempty" json:"owner,omitempty"` + Id string `xml:"id,attr" json:"id"` + Name string `xml:"name,attr" json:"name"` + Comment string `xml:"comment,attr,omitempty" json:"comment,omitempty"` + SongCount int `xml:"songCount,attr,omitempty" json:"songCount,omitempty"` + Duration int `xml:"duration,attr,omitempty" json:"duration,omitempty"` + Public bool `xml:"public,attr,omitempty" json:"public,omitempty"` + Owner string `xml:"owner,attr,omitempty" json:"owner,omitempty"` + Created *time.Time `xml:"created,attr,omitempty" json:"created,omitempty"` + Changed *time.Time `xml:"changed,attr,omitempty" json:"changed,omitempty"` /* - - - - */ } diff --git a/server/subsonic/responses/responses_test.go b/server/subsonic/responses/responses_test.go index 8ca719bbc..f5164b5e6 100644 --- a/server/subsonic/responses/responses_test.go +++ b/server/subsonic/responses/responses_test.go @@ -235,9 +235,20 @@ var _ = Describe("Responses", func() { }) Context("with data", func() { + timestamp, _ := time.Parse(time.RFC3339, "2020-04-11T16:43:00Z04:00") BeforeEach(func() { pls := make([]Playlist, 2) - pls[0] = Playlist{Id: "111", Name: "aaa"} + pls[0] = Playlist{ + Id: "111", + Name: "aaa", + Comment: "comment", + SongCount: 2, + Duration: 120, + Public: true, + Owner: "admin", + Created: ×tamp, + Changed: ×tamp, + } pls[1] = Playlist{Id: "222", Name: "bbb"} response.Playlists.Playlist = pls })