Add created and changed fields to playlists responses

This commit is contained in:
Deluan 2020-04-11 16:45:21 -04:00
parent 803a5776ae
commit e232c5c561
12 changed files with 163 additions and 68 deletions

View file

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

View file

@ -2,6 +2,7 @@ package engine
import ( import (
"context" "context"
"time"
"github.com/deluan/navidrome/model" "github.com/deluan/navidrome/model"
"github.com/deluan/navidrome/utils" "github.com/deluan/navidrome/utils"
@ -118,6 +119,8 @@ type PlaylistInfo struct {
Public bool Public bool
Owner string Owner string
Comment string Comment string
Created time.Time
Changed time.Time
} }
func (p *playlists) Get(ctx context.Context, id string) (*PlaylistInfo, error) { 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, Public: pl.Public,
Owner: pl.Owner, Owner: pl.Owner,
Comment: pl.Comment, Comment: pl.Comment,
Changed: pl.UpdatedAt,
Created: pl.CreatedAt,
} }
plsInfo.Entries = FromMediaFiles(pl.Tracks) plsInfo.Entries = FromMediaFiles(pl.Tracks)

View file

@ -1,5 +1,7 @@
package model package model
import "time"
type Playlist struct { type Playlist struct {
ID string ID string
Name string Name string
@ -8,6 +10,8 @@ type Playlist struct {
Owner string Owner string
Public bool Public bool
Tracks MediaFiles Tracks MediaFiles
CreatedAt time.Time
UpdatedAt time.Time
} }
type PlaylistRepository interface { type PlaylistRepository interface {

View file

@ -68,7 +68,6 @@ var (
ID: "10", ID: "10",
Name: "Best", Name: "Best",
Comment: "No Comments", Comment: "No Comments",
Duration: 10,
Owner: "userid", Owner: "userid",
Public: true, Public: true,
Tracks: model.MediaFiles{{ID: "1"}, {ID: "3"}}, Tracks: model.MediaFiles{{ID: "1"}, {ID: "3"}},

View file

@ -3,9 +3,11 @@ package persistence
import ( import (
"context" "context"
"strings" "strings"
"time"
. "github.com/Masterminds/squirrel" . "github.com/Masterminds/squirrel"
"github.com/astaxie/beego/orm" "github.com/astaxie/beego/orm"
"github.com/deluan/navidrome/log"
"github.com/deluan/navidrome/model" "github.com/deluan/navidrome/model"
) )
@ -17,6 +19,8 @@ type playlist struct {
Owner string Owner string
Public bool Public bool
Tracks string Tracks string
CreatedAt time.Time
UpdatedAt time.Time
} }
type playlistRepository struct { type playlistRepository struct {
@ -44,6 +48,10 @@ func (r *playlistRepository) Delete(id string) error {
} }
func (r *playlistRepository) Put(p *model.Playlist) error { func (r *playlistRepository) Put(p *model.Playlist) error {
if p.ID == "" {
p.CreatedAt = time.Now()
}
p.UpdatedAt = time.Now()
pls := r.fromModel(p) pls := r.fromModel(p)
_, err := r.put(pls.ID, pls) _, err := r.put(pls.ID, pls)
return err return err
@ -62,18 +70,11 @@ func (r *playlistRepository) GetWithTracks(id string) (*model.Playlist, error) {
if err != nil { if err != nil {
return nil, err return nil, err
} }
mfRepo := NewMediaFileRepository(r.ctx, r.ormer)
pls.Duration = 0 pls.Duration = 0
newTracks := model.MediaFiles{} pls.Tracks = r.loadTracks(pls)
for _, t := range pls.Tracks { for _, t := range pls.Tracks {
mf, err := mfRepo.Get(t.ID) pls.Duration += t.Duration
if err != nil {
continue
} }
pls.Duration += mf.Duration
newTracks = append(newTracks, *mf)
}
pls.Tracks = newTracks
return pls, err return pls, err
} }
@ -100,6 +101,8 @@ func (r *playlistRepository) toModel(p *playlist) model.Playlist {
Duration: p.Duration, Duration: p.Duration,
Owner: p.Owner, Owner: p.Owner,
Public: p.Public, Public: p.Public,
CreatedAt: p.CreatedAt,
UpdatedAt: p.UpdatedAt,
} }
if strings.TrimSpace(p.Tracks) != "" { if strings.TrimSpace(p.Tracks) != "" {
tracks := strings.Split(p.Tracks, ",") tracks := strings.Split(p.Tracks, ",")
@ -107,6 +110,7 @@ func (r *playlistRepository) toModel(p *playlist) model.Playlist {
pls.Tracks = append(pls.Tracks, model.MediaFile{ID: t}) pls.Tracks = append(pls.Tracks, model.MediaFile{ID: t})
} }
} }
pls.Tracks = r.loadTracks(&pls)
return pls return pls
} }
@ -115,16 +119,35 @@ func (r *playlistRepository) fromModel(p *model.Playlist) playlist {
ID: p.ID, ID: p.ID,
Name: p.Name, Name: p.Name,
Comment: p.Comment, Comment: p.Comment,
Duration: p.Duration,
Owner: p.Owner, Owner: p.Owner,
Public: p.Public, Public: p.Public,
CreatedAt: p.CreatedAt,
UpdatedAt: p.UpdatedAt,
} }
p.Tracks = r.loadTracks(p)
var newTracks []string var newTracks []string
for _, t := range p.Tracks { for _, t := range p.Tracks {
newTracks = append(newTracks, t.ID) newTracks = append(newTracks, t.ID)
pls.Duration += t.Duration
} }
pls.Tracks = strings.Join(newTracks, ",") pls.Tracks = strings.Join(newTracks, ",")
return pls 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) var _ model.PlaylistRepository = (*playlistRepository)(nil)

View file

@ -32,7 +32,18 @@ var _ = Describe("PlaylistRepository", func() {
Describe("Get", func() { Describe("Get", func() {
It("returns an existing playlist", 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() { It("returns ErrNotFound for a non-existing playlist", func() {
_, err := repo.Get("666") _, err := repo.Get("666")
@ -40,20 +51,22 @@ var _ = Describe("PlaylistRepository", func() {
}) })
}) })
Describe("Put/Get/Delete", func() { Describe("Put/Exists/Delete", func() {
newPls := model.Playlist{ID: "22", Name: "Great!", Tracks: model.MediaFiles{{ID: "4"}}} 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() { It("saves the playlist to the DB", func() {
Expect(repo.Put(&newPls)).To(BeNil()) Expect(repo.Put(&newPls)).To(BeNil())
}) })
It("returns the newly created playlist", func() { 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() { It("returns deletes the playlist", func() {
Expect(repo.Delete("22")).To(BeNil()) Expect(repo.Delete("22")).To(BeNil())
}) })
It("returns error if tries to retrieve the deleted playlist", func() { It("returns error if tries to retrieve the deleted playlist", func() {
_, err := repo.Get("22") Expect(repo.Exists("22")).To(BeFalse())
Expect(err).To(MatchError(model.ErrNotFound))
}) })
}) })
@ -71,7 +84,10 @@ var _ = Describe("PlaylistRepository", func() {
Describe("GetAll", func() { Describe("GetAll", func() {
It("returns all playlists from DB", 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))
}) })
}) })
}) })

View file

@ -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) { func (r sqlRepository) put(id string, m interface{}) (newId string, err error) {
values, _ := toSqlArgs(m) values, _ := toSqlArgs(m)
// Remove created_at from args and save it for later, if needed fo insert
createdAt := values["created_at"] createdAt := values["created_at"]
delete(values, "created_at") delete(values, "created_at")
if id != "" { if id != "" {
@ -178,6 +179,7 @@ func (r sqlRepository) put(id string, m interface{}) (newId string, err error) {
id = rand.String() id = rand.String()
values["id"] = id values["id"] = id
} }
// It is a insert, if there was a created_at, add it back to args
if createdAt != nil { if createdAt != nil {
values["created_at"] = createdAt values["created_at"] = createdAt
} }

View file

@ -36,6 +36,8 @@ func (c *PlaylistsController) GetPlaylists(w http.ResponseWriter, r *http.Reques
playlists[i].Duration = int(p.Duration) playlists[i].Duration = int(p.Duration)
playlists[i].Owner = p.Owner playlists[i].Owner = p.Owner
playlists[i].Public = p.Public playlists[i].Public = p.Public
playlists[i].Created = &p.CreatedAt
playlists[i].Changed = &p.UpdatedAt
} }
response := NewResponse() response := NewResponse()
response.Playlists = &responses.Playlists{Playlist: playlists} response.Playlists = &responses.Playlists{Playlist: playlists}
@ -58,7 +60,7 @@ func (c *PlaylistsController) GetPlaylist(w http.ResponseWriter, r *http.Request
} }
response := NewResponse() response := NewResponse()
response.Playlist = c.buildPlaylist(r.Context(), pinfo) response.Playlist = c.buildPlaylistWithSongs(r.Context(), pinfo)
return response, nil return response, nil
} }
@ -125,15 +127,24 @@ func (c *PlaylistsController) UpdatePlaylist(w http.ResponseWriter, r *http.Requ
return NewResponse(), nil return NewResponse(), nil
} }
func (c *PlaylistsController) buildPlaylist(ctx context.Context, d *engine.PlaylistInfo) *responses.PlaylistWithSongs { func (c *PlaylistsController) buildPlaylistWithSongs(ctx context.Context, d *engine.PlaylistInfo) *responses.PlaylistWithSongs {
pls := &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.Id = d.Id
pls.Name = d.Name pls.Name = d.Name
pls.Comment = d.Comment
pls.SongCount = d.SongCount pls.SongCount = d.SongCount
pls.Owner = d.Owner pls.Owner = d.Owner
pls.Duration = d.Duration pls.Duration = d.Duration
pls.Public = d.Public pls.Public = d.Public
pls.Created = &d.Created
pls.Entry = ToChildren(ctx, d.Entries) pls.Changed = &d.Changed
return pls return pls
} }

View file

@ -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"}]}}

View file

@ -1 +1 @@
<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.8.0" type="navidrome" serverVersion="v0.0.0"><playlists><playlist id="111" name="aaa"></playlist><playlist id="222" name="bbb"></playlist></playlists></subsonic-response> <subsonic-response xmlns="http://subsonic.org/restapi" 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"></playlist><playlist id="222" name="bbb"></playlist></playlists></subsonic-response>

View file

@ -195,15 +195,13 @@ type Playlist struct {
Duration int `xml:"duration,attr,omitempty" json:"duration,omitempty"` Duration int `xml:"duration,attr,omitempty" json:"duration,omitempty"`
Public bool `xml:"public,attr,omitempty" json:"public,omitempty"` Public bool `xml:"public,attr,omitempty" json:"public,omitempty"`
Owner string `xml:"owner,attr,omitempty" json:"owner,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"`
/* /*
<xs:sequence> <xs:sequence>
<xs:element name="allowedUser" type="xs:string" minOccurs="0" maxOccurs="unbounded"/> <!--Added in 1.8.0--> <xs:element name="allowedUser" type="xs:string" minOccurs="0" maxOccurs="unbounded"/> <!--Added in 1.8.0-->
</xs:sequence> </xs:sequence>
<xs:attribute name="comment" type="xs:string" use="optional"/> <!--Added in 1.8.0-->
<xs:attribute name="created" type="xs:dateTime" use="required"/> <!--Added in 1.8.0-->
<xs:attribute name="changed" type="xs:dateTime" use="required"/> <!--Added in 1.13.0-->
<xs:attribute name="coverArt" type="xs:string" use="optional"/> <!--Added in 1.11.0--> <xs:attribute name="coverArt" type="xs:string" use="optional"/> <!--Added in 1.11.0-->
*/ */
} }

View file

@ -235,9 +235,20 @@ var _ = Describe("Responses", func() {
}) })
Context("with data", func() { Context("with data", func() {
timestamp, _ := time.Parse(time.RFC3339, "2020-04-11T16:43:00Z04:00")
BeforeEach(func() { BeforeEach(func() {
pls := make([]Playlist, 2) 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: &timestamp,
Changed: &timestamp,
}
pls[1] = Playlist{Id: "222", Name: "bbb"} pls[1] = Playlist{Id: "222", Name: "bbb"}
response.Playlists.Playlist = pls response.Playlists.Playlist = pls
}) })