diff --git a/api/get_album_list.go b/api/get_album_list.go new file mode 100644 index 000000000..d3dafc170 --- /dev/null +++ b/api/get_album_list.go @@ -0,0 +1,67 @@ +package api + +import ( + "github.com/astaxie/beego" + "github.com/deluan/gosonic/api/responses" + "github.com/deluan/gosonic/domain" + "github.com/deluan/gosonic/utils" + "github.com/karlkfi/inject" + "time" +) + +type GetAlbumListController struct { + BaseAPIController + albumRepo domain.AlbumRepository + types map[string]domain.QueryOptions +} + +func (c *GetAlbumListController) Prepare() { + inject.ExtractAssignable(utils.Graph, &c.albumRepo) + + // TODO To implement other types, we need to fix album data at import time + c.types = map[string]domain.QueryOptions{ + "newest": domain.QueryOptions{SortBy: "CreatedAt", Desc: true, Alpha: true}, + } +} + +func (c *GetAlbumListController) Get() { + typ := c.GetParameter("type", "Required string parameter 'type' is not present") + qo, found := c.types[typ] + + if !found { + beego.Error("getAlbumList type", typ, "not implemented!") + c.SendError(responses.ERROR_GENERIC, "Not implemented yet!") + } + + qo.Size = 10 + c.Ctx.Input.Bind(&qo.Size, "size") + c.Ctx.Input.Bind(&qo.Offset, "offset") + + albums, err := c.albumRepo.GetAll(qo) + if err != nil { + beego.Error("Error retrieving albums:", err) + c.SendError(responses.ERROR_GENERIC, "Internal Error") + } + + albumList := make([]responses.Child, len(albums)) + + for i, al := range albums { + albumList[i].Id = al.Id + albumList[i].Title = al.Name + albumList[i].Parent = al.ArtistId + albumList[i].IsDir = true + albumList[i].Album = al.Name + albumList[i].Year = al.Year + albumList[i].Artist = al.Artist + albumList[i].Genre = al.Genre + albumList[i].CoverArt = al.CoverArtId + if al.Starred { + t := time.Now() + albumList[i].Starred = &t + } + } + + response := c.NewEmpty() + response.AlbumList = &responses.AlbumList{Album: albumList} + c.SendResponse(response) +} diff --git a/api/get_album_list_test.go b/api/get_album_list_test.go new file mode 100644 index 000000000..acc72d5e6 --- /dev/null +++ b/api/get_album_list_test.go @@ -0,0 +1,56 @@ +package api_test + +import ( + "testing" + + "github.com/deluan/gosonic/api/responses" + "github.com/deluan/gosonic/domain" + . "github.com/deluan/gosonic/tests" + "github.com/deluan/gosonic/tests/mocks" + "github.com/deluan/gosonic/utils" + . "github.com/smartystreets/goconvey/convey" +) + +func TestGetAlbumList(t *testing.T) { + Init(t, false) + + mockAlbumRepo := mocks.CreateMockAlbumRepo() + utils.DefineSingleton(new(domain.AlbumRepository), func() domain.AlbumRepository { + return mockAlbumRepo + }) + + Convey("Subject: GetAlbumList Endpoint", t, func() { + mockAlbumRepo.SetData(`[ + {"Id":"A","Name":"Vagarosa","ArtistId":"2"}, + {"Id":"C","Name":"Liberation: The Island Anthology","ArtistId":"3"}, + {"Id":"B","Name":"Planet Rock","ArtistId":"1"}]`, 1) + + Convey("Should fail if missing 'type' parameter", func() { + _, w := Get(AddParams("/rest/getAlbumList.view"), "TestGetAlbumList") + + So(w.Body, ShouldReceiveError, responses.ERROR_MISSING_PARAMETER) + }) + Convey("Return fail on Album Table error", func() { + mockAlbumRepo.SetError(true) + _, w := Get(AddParams("/rest/getAlbumList.view", "type=newest"), "TestGetAlbumList") + + So(w.Body, ShouldReceiveError, responses.ERROR_GENERIC) + }) + Convey("Type is invalid", func() { + _, w := Get(AddParams("/rest/getAlbumList.view", "type=not_implemented"), "TestGetAlbumList") + + So(w.Body, ShouldReceiveError, responses.ERROR_GENERIC) + }) + Convey("Type == newest", func() { + _, w := Get(AddParams("/rest/getAlbumList.view", "type=newest"), "TestGetAlbumList") + So(w.Body, ShouldBeAValid, responses.AlbumList{}) + So(mockAlbumRepo.Options.SortBy, ShouldEqual, "CreatedAt") + So(mockAlbumRepo.Options.Desc, ShouldBeTrue) + So(mockAlbumRepo.Options.Alpha, ShouldBeTrue) + }) + Reset(func() { + mockAlbumRepo.SetData("[]", 0) + mockAlbumRepo.SetError(false) + }) + }) +} diff --git a/api/get_music_directory.go b/api/get_music_directory.go index e28e755e7..02a68c947 100644 --- a/api/get_music_directory.go +++ b/api/get_music_directory.go @@ -50,6 +50,7 @@ func (c *GetMusicDirectoryController) buildArtistDir(a *domain.Artist, albums [] dir.Child[i].Id = al.Id dir.Child[i].Title = al.Name dir.Child[i].IsDir = true + dir.Child[i].Parent = al.ArtistId dir.Child[i].Album = al.Name dir.Child[i].Year = al.Year dir.Child[i].Artist = al.Artist @@ -72,6 +73,7 @@ func (c *GetMusicDirectoryController) buildAlbumDir(al *domain.Album, tracks []d dir.Child[i].Id = mf.Id dir.Child[i].Title = mf.Title dir.Child[i].IsDir = false + dir.Child[i].Parent = mf.AlbumId dir.Child[i].Album = mf.Album dir.Child[i].Year = mf.Year dir.Child[i].Artist = mf.Artist diff --git a/api/get_music_directory_test.go b/api/get_music_directory_test.go index 1b4ee90c9..8f1f1a868 100644 --- a/api/get_music_directory_test.go +++ b/api/get_music_directory_test.go @@ -59,7 +59,7 @@ func TestGetMusicDirectory(t *testing.T) { mockAlbumRepo.SetData(`[{"Id":"A","Name":"Tardis","ArtistId":"1"}]`, 1) _, w := Get(AddParams("/rest/getMusicDirectory.view", "id=1"), "TestGetMusicDirectory") - So(w.Body, ShouldContainJSON, `"child":[{"album":"Tardis","id":"A","isDir":true,"title":"Tardis"}]`) + So(w.Body, ShouldContainJSON, `"child":[{"album":"Tardis","id":"A","isDir":true,"parent":"1","title":"Tardis"}]`) }) }) Convey("When id matches an album with tracks", func() { @@ -68,7 +68,7 @@ func TestGetMusicDirectory(t *testing.T) { mockMediaFileRepo.SetData(`[{"Id":"3","Title":"Cangote","AlbumId":"A"}]`, 1) _, w := Get(AddParams("/rest/getMusicDirectory.view", "id=A"), "TestGetMusicDirectory") - So(w.Body, ShouldContainJSON, `"child":[{"id":"3","isDir":false,"title":"Cangote"}]`) + So(w.Body, ShouldContainJSON, `"child":[{"id":"3","isDir":false,"parent":"A","title":"Cangote"}]`) }) Reset(func() { mockArtistRepo.SetData("[]", 0) diff --git a/api/responses/responses.go b/api/responses/responses.go index fe701dbf2..1b6355602 100644 --- a/api/responses/responses.go +++ b/api/responses/responses.go @@ -14,7 +14,8 @@ type Subsonic struct { MusicFolders *MusicFolders `xml:"musicFolders,omitempty" json:"musicFolders,omitempty"` Indexes *Indexes `xml:"indexes,omitempty" json:"indexes,omitempty"` Directory *Directory `xml:"directory,omitempty" json:"directory,omitempty"` - User *User `xml:"user,omitempty" json:"user,omitempty"` + User *User `xml:"user,omitempty" json:"user,omitempty"` + AlbumList *AlbumList `xml:"albumList,omitempty" json:"albumList,omitempty"` } type JsonWrapper struct { @@ -57,6 +58,7 @@ type Indexes struct { type Child struct { Id string `xml:"id,attr" json:"id"` + Parent string `xml:"parent,attr,omitempty" json:"parent,omitempty"` IsDir bool `xml:"isDir,attr" json:"isDir"` Title string `xml:"title,attr" json:"title"` Album string `xml:"album,attr,omitempty" json:"album,omitempty"` @@ -81,22 +83,26 @@ type Directory struct { Name string `xml:"name,attr" json:"name"` } +type AlbumList struct { + Album []Child `xml:"album" json:"album,omitempty"` +} + type User struct { Username string `xml:"username,attr" json:"username"` Email string `xml:"email,attr,omitempty" json:"email,omitempty"` - ScrobblingEnabled bool `xml:"scrobblingEnabled,attr" json:"scrobblingEnabled"` - MaxBitRate int `xml:"maxBitRate,attr,omitempty" json:"maxBitRate,omitempty"` - AdminRole bool `xml:"adminRole,attr" json:"adminRole"` - SettingsRole bool `xml:"settingsRole,attr" json:"settingsRole"` - DownloadRole bool `xml:"downloadRole,attr" json:"downloadRole"` - UploadRole bool `xml:"uploadRole,attr" json:"uploadRole"` - PlaylistRole bool `xml:"playlistRole,attr" json:"playlistRole"` - CoverArtRole bool `xml:"coverArtRole,attr" json:"coverArtRole"` - CommentRole bool `xml:"commentRole,attr" json:"commentRole"` - PodcastRole bool `xml:"podcastRole,attr" json:"podcastRole"` - StreamRole bool `xml:"streamRole,attr" json:"streamRole"` - JukeboxRole bool `xml:"jukeboxRole,attr" json:"jukeboxRole"` - ShareRole bool `xml:"shareRole,attr" json:"shareRole"` - VideoConversionRole bool `xml:"videoConversionRole,attr" json:"videoConversionRole"` - Folder []int `xml:"folder,omitempty" json:"folder,omitempty"` + ScrobblingEnabled bool `xml:"scrobblingEnabled,attr" json:"scrobblingEnabled"` + MaxBitRate int `xml:"maxBitRate,attr,omitempty" json:"maxBitRate,omitempty"` + AdminRole bool `xml:"adminRole,attr" json:"adminRole"` + SettingsRole bool `xml:"settingsRole,attr" json:"settingsRole"` + DownloadRole bool `xml:"downloadRole,attr" json:"downloadRole"` + UploadRole bool `xml:"uploadRole,attr" json:"uploadRole"` + PlaylistRole bool `xml:"playlistRole,attr" json:"playlistRole"` + CoverArtRole bool `xml:"coverArtRole,attr" json:"coverArtRole"` + CommentRole bool `xml:"commentRole,attr" json:"commentRole"` + PodcastRole bool `xml:"podcastRole,attr" json:"podcastRole"` + StreamRole bool `xml:"streamRole,attr" json:"streamRole"` + JukeboxRole bool `xml:"jukeboxRole,attr" json:"jukeboxRole"` + ShareRole bool `xml:"shareRole,attr" json:"shareRole"` + VideoConversionRole bool `xml:"videoConversionRole,attr" json:"videoConversionRole"` + Folder []int `xml:"folder,omitempty" json:"folder,omitempty"` } diff --git a/api/responses/responses_test.go b/api/responses/responses_test.go index 7e4e4abd3..af7a68670 100644 --- a/api/responses/responses_test.go +++ b/api/responses/responses_test.go @@ -84,6 +84,27 @@ func TestSubsonicResponses(t *testing.T) { }) }) + Convey("Child", func() { + response.Directory = &Directory{Id: "1", Name: "N"} + Convey("With all data", func() { + child := make([]Child, 1) + t := time.Date(2016, 03, 2, 20, 30, 0, 0, time.UTC) + child[0] = Child{ + Id: "1", IsDir: true, Title: "title", Album: "album", Artist: "artist", Track: 1, + Year: 1985, Genre: "Rock", CoverArt: "1", Size: "8421341", ContentType: "audio/flac", + Suffix: "flac", TranscodedContentType: "audio/mpeg", TranscodedSuffix: "mp3", + Duration: 146, BitRate: 320, Starred: &t, + } + response.Directory.Child = child + Convey("XML", func() { + So(response, ShouldMatchXML, ``) + }) + Convey("JSON", func() { + So(response, ShouldMatchJSON, `{"directory":{"child":[{"album":"album","artist":"artist","bitRate":320,"contentType":"audio/flac","coverArt":"1","duration":146,"genre":"Rock","id":"1","isDir":true,"size":"8421341","starred":"2016-03-02T20:30:00Z","suffix":"flac","title":"title","track":1,"transcodedContentType":"audio/mpeg","transcodedSuffix":"mp3","year":1985}],"id":"1","name":"N"},"status":"ok","version":"1.0.0"}`) + }) + }) + }) + Convey("Directory", func() { response.Directory = &Directory{Id: "1", Name: "N"} Convey("Without data", func() { @@ -105,21 +126,27 @@ func TestSubsonicResponses(t *testing.T) { So(response, ShouldMatchJSON, `{"directory":{"child":[{"id":"1","isDir":false,"title":"title"}],"id":"1","name":"N"},"status":"ok","version":"1.0.0"}`) }) }) - Convey("With all data", func() { - child := make([]Child, 1) - t := time.Date(2016, 03, 2, 20, 30, 0, 0, time.UTC) - child[0] = Child{ - Id: "1", IsDir: true, Title: "title", Album: "album", Artist: "artist", Track: 1, - Year: 1985, Genre: "Rock", CoverArt: "1", Size: "8421341", ContentType: "audio/flac", - Suffix: "flac", TranscodedContentType: "audio/mpeg", TranscodedSuffix: "mp3", - Duration: 146, BitRate: 320, Starred: &t, - } - response.Directory.Child = child + }) + + Convey("AlbumList", func() { + response.AlbumList = &AlbumList{} + Convey("Without data", func() { Convey("XML", func() { - So(response, ShouldMatchXML, ``) + So(response, ShouldMatchXML, ``) }) Convey("JSON", func() { - So(response, ShouldMatchJSON, `{"directory":{"child":[{"album":"album","artist":"artist","bitRate":320,"contentType":"audio/flac","coverArt":"1","duration":146,"genre":"Rock","id":"1","isDir":true,"size":"8421341","starred":"2016-03-02T20:30:00Z","suffix":"flac","title":"title","track":1,"transcodedContentType":"audio/mpeg","transcodedSuffix":"mp3","year":1985}],"id":"1","name":"N"},"status":"ok","version":"1.0.0"}`) + So(response, ShouldMatchJSON, `{"albumList":{},"status":"ok","version":"1.0.0"}`) + }) + }) + Convey("With just required data", func() { + child := make([]Child, 1) + child[0] = Child{Id: "1", Title: "title", IsDir: false} + response.AlbumList.Album = child + Convey("XML", func() { + So(response, ShouldMatchXML, ``) + }) + Convey("JSON", func() { + So(response, ShouldMatchJSON, `{"albumList":{"album":[{"id":"1","isDir":false,"title":"title"}]},"status":"ok","version":"1.0.0"}`) }) }) }) diff --git a/conf/router.go b/conf/router.go index f5c93fe15..f58b2fc25 100644 --- a/conf/router.go +++ b/conf/router.go @@ -25,6 +25,7 @@ func mapEndpoints() { beego.NSRouter("/stream.view", &api.StreamController{}, "*:Get"), beego.NSRouter("/download.view", &api.StreamController{}, "*:Get"), beego.NSRouter("/getUser.view", &api.UsersController{}, "*:GetUser"), + beego.NSRouter("/getAlbumList.view", &api.GetAlbumListController{}, "*:Get"), ) beego.AddNamespace(ns) diff --git a/domain/album.go b/domain/album.go index 2652893e5..0f14560a8 100644 --- a/domain/album.go +++ b/domain/album.go @@ -1,5 +1,7 @@ package domain +import "time" + type Album struct { Id string Name string @@ -13,6 +15,8 @@ type Album struct { Starred bool Rating int Genre string + CreatedAt time.Time + UpdatedAt time.Time } type Albums []Album @@ -22,4 +26,5 @@ type AlbumRepository interface { Put(m *Album) error Get(id string) (*Album, error) FindByArtist(artistId string) (Albums, error) + GetAll(QueryOptions) (Albums, error) } diff --git a/persistence/album_repository.go b/persistence/album_repository.go index f99e288f0..d28c513a7 100644 --- a/persistence/album_repository.go +++ b/persistence/album_repository.go @@ -29,8 +29,14 @@ func (r *albumRepository) Get(id string) (*domain.Album, error) { func (r *albumRepository) FindByArtist(artistId string) (domain.Albums, error) { var as = make(domain.Albums, 0) - err := r.loadChildren("artist", artistId, &as, domain.QueryOptions{SortBy:"Year"}) + err := r.loadChildren("artist", artistId, &as, domain.QueryOptions{SortBy: "Year"}) return as, err } -var _ domain.AlbumRepository = (*albumRepository)(nil) \ No newline at end of file +func (r *albumRepository) GetAll(options domain.QueryOptions) (domain.Albums, error) { + var as = make(domain.Albums, 0) + err := r.loadAll(&as, options) + return as, err +} + +var _ domain.AlbumRepository = (*albumRepository)(nil) diff --git a/scanner/importer.go b/scanner/importer.go index e7a4878a6..45215fd25 100644 --- a/scanner/importer.go +++ b/scanner/importer.go @@ -3,14 +3,14 @@ package scanner import ( "fmt" "github.com/astaxie/beego" + "github.com/deluan/gosonic/consts" "github.com/deluan/gosonic/domain" "github.com/deluan/gosonic/persistence" - "time" - "github.com/dhowden/tag" "github.com/deluan/gosonic/utils" - "github.com/deluan/gosonic/consts" + "github.com/dhowden/tag" "os" "strings" + "time" ) type Scanner interface { @@ -22,14 +22,13 @@ type tempIndex map[string]domain.ArtistInfo func StartImport() { go func() { i := &Importer{ - scanner: &ItunesScanner{}, - mediaFolder: beego.AppConfig.String("musicFolder"), - mfRepo: persistence.NewMediaFileRepository(), - albumRepo:persistence.NewAlbumRepository(), - artistRepo: persistence.NewArtistRepository(), - idxRepo: persistence.NewArtistIndexRepository(), + scanner: &ItunesScanner{}, + mediaFolder: beego.AppConfig.String("musicFolder"), + mfRepo: persistence.NewMediaFileRepository(), + albumRepo: persistence.NewAlbumRepository(), + artistRepo: persistence.NewArtistRepository(), + idxRepo: persistence.NewArtistIndexRepository(), propertyRepo: persistence.NewPropertyRepository(), - } i.Run() }() @@ -134,6 +133,8 @@ func (i *Importer) parseTrack(t *Track) (*domain.MediaFile, *domain.Album, *doma Genre: t.Genre, Artist: t.Artist, AlbumArtist: t.AlbumArtist, + CreatedAt: t.CreatedAt, // TODO Collect all songs for an album first + UpdatedAt: t.UpdatedAt, } if mf.HasCoverArt { diff --git a/tests/matchers.go b/tests/matchers.go index 1d142588a..33009d2f0 100644 --- a/tests/matchers.go +++ b/tests/matchers.go @@ -2,12 +2,12 @@ package tests import ( "bytes" + "crypto/md5" "encoding/json" "encoding/xml" "fmt" "github.com/deluan/gosonic/api/responses" . "github.com/smartystreets/goconvey/convey" - "crypto/md5" ) func ShouldMatchXML(actual interface{}, expected ...interface{}) string { @@ -49,6 +49,16 @@ func ShouldMatchMD5(actual interface{}, expected ...interface{}) string { return ShouldEqual(a, expected[0].(string)) } +func ShouldBeAValid(actual interface{}, expected ...interface{}) string { + v := responses.Subsonic{} + err := json.Unmarshal(actual.(*bytes.Buffer).Bytes(), &v) + if err != nil { + return fmt.Sprintf("Malformed response: %v", err) + } + + return "" +} + func UnindentJSON(j []byte) string { var m = make(map[string]interface{}) json.Unmarshal(j, &m) diff --git a/tests/mocks/mock_album_repo.go b/tests/mocks/mock_album_repo.go index 2a1feb973..f3c923f48 100644 --- a/tests/mocks/mock_album_repo.go +++ b/tests/mocks/mock_album_repo.go @@ -13,8 +13,10 @@ func CreateMockAlbumRepo() *MockAlbum { type MockAlbum struct { domain.AlbumRepository - data map[string]*domain.Album - err bool + data map[string]*domain.Album + all domain.Albums + err bool + Options domain.QueryOptions } func (m *MockAlbum) SetError(err bool) { @@ -23,12 +25,12 @@ func (m *MockAlbum) SetError(err bool) { func (m *MockAlbum) SetData(j string, size int) { m.data = make(map[string]*domain.Album) - var l = make([]domain.Album, size) - err := json.Unmarshal([]byte(j), &l) + m.all = make(domain.Albums, size) + err := json.Unmarshal([]byte(j), &m.all) if err != nil { fmt.Println("ERROR: ", err) } - for _, a := range l { + for _, a := range m.all { m.data[a.Id] = &a } } @@ -48,6 +50,14 @@ func (m *MockAlbum) Get(id string) (*domain.Album, error) { return m.data[id], nil } +func (m *MockAlbum) GetAll(qo domain.QueryOptions) (domain.Albums, error) { + m.Options = qo + if m.err { + return nil, errors.New("Error!") + } + return m.all, nil +} + func (m *MockAlbum) FindByArtist(artistId string) (domain.Albums, error) { if m.err { return nil, errors.New("Error!")