diff --git a/api/api.go b/api/api.go index a4a05747f..c6b0a5def 100644 --- a/api/api.go +++ b/api/api.go @@ -64,6 +64,7 @@ func (api *Router) routes() http.Handler { H(r, "getMusicFolders", c.GetMusicFolders) H(r, "getIndexes", c.GetIndexes) H(r, "getArtists", c.GetArtists) + H(r, "getGenres", c.GetGenres) reqParams := r.With(requiredParams("id")) H(reqParams, "getMusicDirectory", c.GetMusicDirectory) H(reqParams, "getArtist", c.GetArtist) diff --git a/api/browsing.go b/api/browsing.go index 7c21ac663..6ecc09f99 100644 --- a/api/browsing.go +++ b/api/browsing.go @@ -151,6 +151,18 @@ func (c *BrowsingController) GetSong(w http.ResponseWriter, r *http.Request) (*r return response, nil } +func (c *BrowsingController) GetGenres(w http.ResponseWriter, r *http.Request) (*responses.Subsonic, error) { + genres, err := c.browser.GetGenres() + if err != nil { + log.Error(r, err) + return nil, NewError(responses.ErrorGeneric, "Internal Error") + } + + response := NewResponse() + response.Genres = ToGenres(genres) + return response, nil +} + func (c *BrowsingController) buildDirectory(d *engine.DirectoryInfo) *responses.Directory { dir := &responses.Directory{ Id: d.Id, diff --git a/api/helpers.go b/api/helpers.go index 33924961e..a8bdfabae 100644 --- a/api/helpers.go +++ b/api/helpers.go @@ -9,6 +9,7 @@ import ( "github.com/cloudsonic/sonic-server/api/responses" "github.com/cloudsonic/sonic-server/engine" + "github.com/cloudsonic/sonic-server/model" "github.com/cloudsonic/sonic-server/utils" ) @@ -185,3 +186,11 @@ func ToChild(entry engine.Entry) responses.Child { child.SongCount = entry.SongCount return child } + +func ToGenres(genres model.Genres) *responses.Genres { + response := make([]responses.Genre, len(genres)) + for i, g := range genres { + response[i] = responses.Genre(g) + } + return &responses.Genres{Genre: response} +} diff --git a/api/responses/.snapshots/responses-snapshotMatcher-Match-Responses Genres with data should match JSON b/api/responses/.snapshots/responses-snapshotMatcher-Match-Responses Genres with data should match JSON new file mode 100644 index 000000000..08a4fb50c --- /dev/null +++ b/api/responses/.snapshots/responses-snapshotMatcher-Match-Responses Genres with data should match JSON @@ -0,0 +1 @@ +{"status":"ok","version":"1.8.0","genres":{"genre":[{"value":"Rock","songCount":1000,"albumCount":100},{"value":"Reggae","songCount":500,"albumCount":50},{"value":"Pop","songCount":0,"albumCount":0}]}} diff --git a/api/responses/.snapshots/responses-snapshotMatcher-Match-Responses Genres with data should match XML b/api/responses/.snapshots/responses-snapshotMatcher-Match-Responses Genres with data should match XML new file mode 100644 index 000000000..e99952d47 --- /dev/null +++ b/api/responses/.snapshots/responses-snapshotMatcher-Match-Responses Genres with data should match XML @@ -0,0 +1 @@ +<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.8.0"><genres><genre songCount="1000" albumCount="100">Rock</genre><genre songCount="500" albumCount="50">Reggae</genre><genre songCount="0" albumCount="0">Pop</genre></genres></subsonic-response> diff --git a/api/responses/.snapshots/responses-snapshotMatcher-Match-Responses Genres without data should match JSON b/api/responses/.snapshots/responses-snapshotMatcher-Match-Responses Genres without data should match JSON new file mode 100644 index 000000000..f7f97bf4e --- /dev/null +++ b/api/responses/.snapshots/responses-snapshotMatcher-Match-Responses Genres without data should match JSON @@ -0,0 +1 @@ +{"status":"ok","version":"1.8.0","genres":{}} diff --git a/api/responses/.snapshots/responses-snapshotMatcher-Match-Responses Genres without data should match XML b/api/responses/.snapshots/responses-snapshotMatcher-Match-Responses Genres without data should match XML new file mode 100644 index 000000000..1d0c9e4b7 --- /dev/null +++ b/api/responses/.snapshots/responses-snapshotMatcher-Match-Responses Genres without data should match XML @@ -0,0 +1 @@ +<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.8.0"><genres></genres></subsonic-response> diff --git a/api/responses/responses.go b/api/responses/responses.go index a699f76d7..50e79e788 100644 --- a/api/responses/responses.go +++ b/api/responses/responses.go @@ -26,6 +26,7 @@ type Subsonic struct { NowPlaying *NowPlaying `xml:"nowPlaying,omitempty" json:"nowPlaying,omitempty"` Song *Child `xml:"song,omitempty" json:"song,omitempty"` RandomSongs *Songs `xml:"randomSongs,omitempty" json:"randomSongs,omitempty"` + Genres *Genres `xml:"genres,omitempty" json:"genres,omitempty"` // ID3 Artist *Indexes `xml:"artists,omitempty" json:"artists,omitempty"` @@ -259,3 +260,13 @@ type User struct { VideoConversionRole bool `xml:"videoConversionRole,attr" json:"videoConversionRole"` Folder []int `xml:"folder,omitempty" json:"folder,omitempty"` } + +type Genre struct { + Name string `xml:",chardata" json:"value,omitempty"` + SongCount int `xml:"songCount,attr" json:"songCount"` + AlbumCount int `xml:"albumCount,attr" json:"albumCount"` +} + +type Genres struct { + Genre []Genre `xml:"genre,omitempty" json:"genre,omitempty"` +} diff --git a/api/responses/responses_test.go b/api/responses/responses_test.go index 4fb0ab819..230999210 100644 --- a/api/responses/responses_test.go +++ b/api/responses/responses_test.go @@ -248,4 +248,36 @@ var _ = Describe("Responses", func() { }) }) }) + + Describe("Genres", func() { + BeforeEach(func() { + response.Genres = &Genres{} + }) + + Context("without data", func() { + It("should match XML", func() { + Expect(xml.Marshal(response)).To(MatchSnapshot()) + }) + It("should match JSON", func() { + Expect(json.Marshal(response)).To(MatchSnapshot()) + }) + }) + + Context("with data", func() { + BeforeEach(func() { + genres := make([]Genre, 3) + genres[0] = Genre{SongCount: 1000, AlbumCount: 100, Name: "Rock"} + genres[1] = Genre{SongCount: 500, AlbumCount: 50, Name: "Reggae"} + genres[2] = Genre{SongCount: 0, AlbumCount: 0, Name: "Pop"} + response.Genres.Genre = genres + }) + + It("should match XML", func() { + Expect(xml.Marshal(response)).To(MatchSnapshot()) + }) + It("should match JSON", func() { + Expect(json.Marshal(response)).To(MatchSnapshot()) + }) + }) + }) }) diff --git a/engine/browser.go b/engine/browser.go index 8b12c4b6a..6222b86ab 100644 --- a/engine/browser.go +++ b/engine/browser.go @@ -3,7 +3,9 @@ package engine import ( "context" "fmt" + "sort" "strconv" + "strings" "time" "github.com/cloudsonic/sonic-server/log" @@ -18,11 +20,12 @@ type Browser interface { Artist(ctx context.Context, id string) (*DirectoryInfo, error) Album(ctx context.Context, id string) (*DirectoryInfo, error) GetSong(id string) (*Entry, error) + GetGenres() (model.Genres, error) } func NewBrowser(pr model.PropertyRepository, fr model.MediaFolderRepository, ir model.ArtistIndexRepository, - ar model.ArtistRepository, alr model.AlbumRepository, mr model.MediaFileRepository) Browser { - return &browser{pr, fr, ir, ar, alr, mr} + ar model.ArtistRepository, alr model.AlbumRepository, mr model.MediaFileRepository, gr model.GenreRepository) Browser { + return &browser{pr, fr, ir, ar, alr, mr, gr} } type browser struct { @@ -32,6 +35,7 @@ type browser struct { artistRepo model.ArtistRepository albumRepo model.AlbumRepository mfileRepo model.MediaFileRepository + genreRepo model.GenreRepository } func (b *browser) MediaFolders() (model.MediaFolders, error) { @@ -114,6 +118,19 @@ func (b *browser) GetSong(id string) (*Entry, error) { return &entry, nil } +func (b *browser) GetGenres() (model.Genres, error) { + genres, err := b.genreRepo.GetAll() + for i, g := range genres { + if strings.TrimSpace(g.Name) == "" { + genres[i].Name = "<Empty>" + } + } + sort.Slice(genres, func(i, j int) bool { + return genres[i].Name < genres[j].Name + }) + return genres, err +} + func (b *browser) buildArtistDir(a *model.Artist, albums model.Albums) *DirectoryInfo { dir := &DirectoryInfo{ Id: a.ID, diff --git a/engine/browser_test.go b/engine/browser_test.go new file mode 100644 index 000000000..30e1b8047 --- /dev/null +++ b/engine/browser_test.go @@ -0,0 +1,49 @@ +package engine + +import ( + "errors" + + "github.com/cloudsonic/sonic-server/model" + . "github.com/onsi/ginkgo" + . "github.com/onsi/gomega" +) + +var _ = Describe("Browser", func() { + var repo *mockGenreRepository + var b Browser + + BeforeSuite(func() { + repo = &mockGenreRepository{data: model.Genres{ + {Name: "Rock", SongCount: 1000, AlbumCount: 100}, + {Name: "", SongCount: 13, AlbumCount: 13}, + {Name: "Electronic", SongCount: 4000, AlbumCount: 40}, + }} + b = &browser{genreRepo: repo} + }) + + It("returns sorted data", func() { + Expect(b.GetGenres()).To(Equal(model.Genres{ + {Name: "<Empty>", SongCount: 13, AlbumCount: 13}, + {Name: "Electronic", SongCount: 4000, AlbumCount: 40}, + {Name: "Rock", SongCount: 1000, AlbumCount: 100}, + })) + }) + + It("bubbles up errors", func() { + repo.err = errors.New("generic error") + _, err := b.GetGenres() + Expect(err).ToNot(BeNil()) + }) +}) + +type mockGenreRepository struct { + data model.Genres + err error +} + +func (r *mockGenreRepository) GetAll() (model.Genres, error) { + if r.err != nil { + return nil, r.err + } + return r.data, nil +} diff --git a/engine/engine_suite_test.go b/engine/engine_suite_test.go new file mode 100644 index 000000000..68f00f6a8 --- /dev/null +++ b/engine/engine_suite_test.go @@ -0,0 +1,15 @@ +package engine + +import ( + "testing" + + "github.com/cloudsonic/sonic-server/log" + . "github.com/onsi/ginkgo" + . "github.com/onsi/gomega" +) + +func TestPersistence(t *testing.T) { + log.SetLevel(log.LevelCritical) + RegisterFailHandler(Fail) + RunSpecs(t, "Persistence Suite") +} diff --git a/model/genres.go b/model/genres.go new file mode 100644 index 000000000..44f153ac8 --- /dev/null +++ b/model/genres.go @@ -0,0 +1,13 @@ +package model + +type Genre struct { + Name string + SongCount int + AlbumCount int +} + +type Genres []Genre + +type GenreRepository interface { + GetAll() (Genres, error) +} diff --git a/persistence/album_repository.go b/persistence/album_repository.go index e545aece8..7e7b5473f 100644 --- a/persistence/album_repository.go +++ b/persistence/album_repository.go @@ -23,7 +23,7 @@ type Album struct { SongCount int `` Duration int `` Rating int `orm:"index"` - Genre string `` + Genre string `orm:"index"` StarredAt time.Time `orm:"null"` CreatedAt time.Time `orm:"null"` UpdatedAt time.Time `orm:"null"` diff --git a/persistence/album_repository_test.go b/persistence/album_repository_test.go index 6d1986fc2..b125c92b5 100644 --- a/persistence/album_repository_test.go +++ b/persistence/album_repository_test.go @@ -20,37 +20,37 @@ var _ = Describe("AlbumRepository", func() { It("returns all records sorted", func() { Expect(repo.GetAll(model.QueryOptions{SortBy: "Name"})).To(Equal(model.Albums{ - {ID: "2", Name: "Abbey Road", Artist: "The Beatles", ArtistID: "1"}, - {ID: "3", Name: "Radioactivity", Artist: "Kraftwerk", ArtistID: "2", Starred: true}, - {ID: "1", Name: "Sgt Peppers", Artist: "The Beatles", ArtistID: "1"}, + albumAbbeyRoad, + albumRadioactivity, + albumSgtPeppers, })) }) It("returns all records sorted desc", func() { Expect(repo.GetAll(model.QueryOptions{SortBy: "Name", Desc: true})).To(Equal(model.Albums{ - {ID: "1", Name: "Sgt Peppers", Artist: "The Beatles", ArtistID: "1"}, - {ID: "3", Name: "Radioactivity", Artist: "Kraftwerk", ArtistID: "2", Starred: true}, - {ID: "2", Name: "Abbey Road", Artist: "The Beatles", ArtistID: "1"}, + albumSgtPeppers, + albumRadioactivity, + albumAbbeyRoad, })) }) It("paginates the result", func() { Expect(repo.GetAll(model.QueryOptions{Offset: 1, Size: 1})).To(Equal(model.Albums{ - {ID: "2", Name: "Abbey Road", Artist: "The Beatles", ArtistID: "1"}, + albumAbbeyRoad, })) }) }) Describe("GetAllIds", func() { It("returns all records", func() { - Expect(repo.GetAllIds()).To(Equal([]string{"1", "2", "3"})) + Expect(repo.GetAllIds()).To(ConsistOf("1", "2", "3")) }) }) Describe("GetStarred", func() { It("returns all starred records", func() { Expect(repo.GetStarred(model.QueryOptions{})).To(Equal(model.Albums{ - {ID: "3", Name: "Radioactivity", Artist: "Kraftwerk", ArtistID: "2", Starred: true}, + albumRadioactivity, })) }) }) @@ -58,8 +58,8 @@ var _ = Describe("AlbumRepository", func() { Describe("FindByArtist", func() { It("returns all records from a given ArtistID", func() { Expect(repo.FindByArtist("1")).To(Equal(model.Albums{ - {ID: "2", Name: "Abbey Road", Artist: "The Beatles", ArtistID: "1"}, - {ID: "1", Name: "Sgt Peppers", Artist: "The Beatles", ArtistID: "1"}, + albumAbbeyRoad, + albumSgtPeppers, })) }) }) diff --git a/persistence/genre_repository.go b/persistence/genre_repository.go new file mode 100644 index 000000000..0d99ef594 --- /dev/null +++ b/persistence/genre_repository.go @@ -0,0 +1,59 @@ +package persistence + +import ( + "strconv" + + "github.com/astaxie/beego/orm" + "github.com/cloudsonic/sonic-server/model" +) + +type genreRepository struct{} + +func NewGenreRepository() model.GenreRepository { + return &genreRepository{} +} + +func (r genreRepository) GetAll() (model.Genres, error) { + o := Db() + genres := make(map[string]model.Genre) + + // Collect SongCount + var res []orm.Params + _, err := o.Raw("select genre, count(*) as c from media_file group by genre").Values(&res) + if err != nil { + return nil, err + } + for _, r := range res { + name := r["genre"].(string) + count := r["c"].(string) + g, ok := genres[name] + if !ok { + g = model.Genre{Name: name} + } + g.SongCount, _ = strconv.Atoi(count) + genres[name] = g + } + + // Collect AlbumCount + _, err = o.Raw("select genre, count(*) as c from album group by genre").Values(&res) + if err != nil { + return nil, err + } + for _, r := range res { + name := r["genre"].(string) + count := r["c"].(string) + g, ok := genres[name] + if !ok { + g = model.Genre{Name: name} + } + g.AlbumCount, _ = strconv.Atoi(count) + genres[name] = g + } + + // Build response + result := model.Genres{} + for _, g := range genres { + result = append(result, g) + } + return result, err +} diff --git a/persistence/genre_repository_test.go b/persistence/genre_repository_test.go new file mode 100644 index 000000000..24a9756a2 --- /dev/null +++ b/persistence/genre_repository_test.go @@ -0,0 +1,22 @@ +package persistence + +import ( + "github.com/cloudsonic/sonic-server/model" + . "github.com/onsi/ginkgo" + . "github.com/onsi/gomega" +) + +var _ = Describe("GenreRepository", func() { + var repo model.GenreRepository + + BeforeEach(func() { + repo = NewGenreRepository() + }) + + It("returns all records", func() { + genres, err := repo.GetAll() + Expect(err).To(BeNil()) + Expect(genres).To(ContainElement(model.Genre{Name: "Rock", AlbumCount: 2, SongCount: 2})) + Expect(genres).To(ContainElement(model.Genre{Name: "Electronic", AlbumCount: 1, SongCount: 2})) + }) +}) diff --git a/persistence/mediafile_repository.go b/persistence/mediafile_repository.go index 183a6cc69..184cf6836 100644 --- a/persistence/mediafile_repository.go +++ b/persistence/mediafile_repository.go @@ -24,7 +24,7 @@ type MediaFile struct { Suffix string `` Duration int `` BitRate int `` - Genre string `` + Genre string `orm:"index"` Compilation bool `` PlayCount int `orm:"index"` PlayDate time.Time `orm:"null"` diff --git a/persistence/persistence_suite_test.go b/persistence/persistence_suite_test.go index fc2dca324..89a763e7c 100644 --- a/persistence/persistence_suite_test.go +++ b/persistence/persistence_suite_test.go @@ -16,19 +16,38 @@ func TestPersistence(t *testing.T) { RunSpecs(t, "Persistence Suite") } -var testAlbums = model.Albums{ - {ID: "1", Name: "Sgt Peppers", Artist: "The Beatles", ArtistID: "1"}, - {ID: "2", Name: "Abbey Road", Artist: "The Beatles", ArtistID: "1"}, - {ID: "3", Name: "Radioactivity", Artist: "Kraftwerk", ArtistID: "2", Starred: true}, -} +var artistSaaraSaara = model.Artist{ID: "1", Name: "Saara Saara", AlbumCount: 2} +var artistKraftwerk = model.Artist{ID: "2", Name: "Kraftwerk"} +var artistBeatles = model.Artist{ID: "3", Name: "The Beatles"} var testArtists = model.Artists{ - {ID: "1", Name: "Saara Saara", AlbumCount: 2}, - {ID: "2", Name: "Kraftwerk"}, - {ID: "3", Name: "The Beatles"}, + artistSaaraSaara, + artistKraftwerk, + artistBeatles, +} + +var albumSgtPeppers = model.Album{ID: "1", Name: "Sgt Peppers", Artist: "The Beatles", ArtistID: "1", Genre: "Rock"} +var albumAbbeyRoad = model.Album{ID: "2", Name: "Abbey Road", Artist: "The Beatles", ArtistID: "1", Genre: "Rock"} +var albumRadioactivity = model.Album{ID: "3", Name: "Radioactivity", Artist: "Kraftwerk", ArtistID: "2", Starred: true, Genre: "Electronic"} +var testAlbums = model.Albums{ + albumSgtPeppers, + albumAbbeyRoad, + albumRadioactivity, +} + +var songDayInALife = model.MediaFile{ID: "1", Title: "A Day In A Life", ArtistID: "3", AlbumID: "1", Genre: "Rock"} +var songComeTogether = model.MediaFile{ID: "2", Title: "Come Together", ArtistID: "3", AlbumID: "2", Genre: "Rock"} +var songRadioactivity = model.MediaFile{ID: "3", Title: "Radioactivity", ArtistID: "2", AlbumID: "3", Genre: "Electronic"} +var songAntenna = model.MediaFile{ID: "4", Title: "Antenna", ArtistID: "2", AlbumID: "3", Genre: "Electronic"} +var testSongs = model.MediaFiles{ + songDayInALife, + songComeTogether, + songRadioactivity, + songAntenna, } var _ = Describe("Initialize test DB", func() { BeforeSuite(func() { + //log.SetLevel(log.LevelTrace) //conf.Sonic.DbPath, _ = ioutil.TempDir("", "cloudsonic_tests") //os.MkdirAll(conf.Sonic.DbPath, 0700) conf.Sonic.DbPath = ":memory:" @@ -44,5 +63,12 @@ var _ = Describe("Initialize test DB", func() { panic(err) } } + mediaFileRepository := NewMediaFileRepository() + for _, s := range testSongs { + err := mediaFileRepository.Put(&s) + if err != nil { + panic(err) + } + } }) }) diff --git a/persistence/wire_provider.go b/persistence/wire_provider.go index 51f49bf6c..2390b983c 100644 --- a/persistence/wire_provider.go +++ b/persistence/wire_provider.go @@ -14,4 +14,5 @@ var Set = wire.NewSet( NewPlaylistRepository, NewNowPlayingRepository, NewMediaFolderRepository, + NewGenreRepository, ) diff --git a/wire_gen.go b/wire_gen.go index eb8e0fdd8..eeaef8f4e 100644 --- a/wire_gen.go +++ b/wire_gen.go @@ -41,7 +41,8 @@ func CreateSubsonicAPIRouter() *api.Router { artistRepository := repositories.ArtistRepository albumRepository := repositories.AlbumRepository mediaFileRepository := repositories.MediaFileRepository - browser := engine.NewBrowser(propertyRepository, mediaFolderRepository, artistIndexRepository, artistRepository, albumRepository, mediaFileRepository) + genreRepository := repositories.GenreRepository + browser := engine.NewBrowser(propertyRepository, mediaFolderRepository, artistIndexRepository, artistRepository, albumRepository, mediaFileRepository, genreRepository) cover := engine.NewCover(mediaFileRepository, albumRepository) nowPlayingRepository := repositories.NowPlayingRepository listGenerator := engine.NewListGenerator(albumRepository, mediaFileRepository, nowPlayingRepository) @@ -65,6 +66,7 @@ func createPersistenceProvider() *Repositories { nowPlayingRepository := persistence.NewNowPlayingRepository() playlistRepository := persistence.NewPlaylistRepository() propertyRepository := persistence.NewPropertyRepository() + genreRepository := persistence.NewGenreRepository() repositories := &Repositories{ AlbumRepository: albumRepository, ArtistRepository: artistRepository, @@ -75,6 +77,7 @@ func createPersistenceProvider() *Repositories { NowPlayingRepository: nowPlayingRepository, PlaylistRepository: playlistRepository, PropertyRepository: propertyRepository, + GenreRepository: genreRepository, } return repositories } @@ -91,9 +94,10 @@ type Repositories struct { NowPlayingRepository model.NowPlayingRepository PlaylistRepository model.PlaylistRepository PropertyRepository model.PropertyRepository + GenreRepository model.GenreRepository } var allProviders = wire.NewSet(itunesbridge.NewItunesControl, engine.Set, scanner_legacy.Set, api.NewRouter, wire.FieldsOf(new(*Repositories), "AlbumRepository", "ArtistRepository", "CheckSumRepository", "ArtistIndexRepository", "MediaFileRepository", "MediaFolderRepository", "NowPlayingRepository", - "PlaylistRepository", "PropertyRepository"), createPersistenceProvider, + "PlaylistRepository", "PropertyRepository", "GenreRepository"), createPersistenceProvider, ) diff --git a/wire_injectors.go b/wire_injectors.go index 99170dc82..326e9787a 100644 --- a/wire_injectors.go +++ b/wire_injectors.go @@ -13,6 +13,7 @@ import ( "github.com/google/wire" ) +// TODO Can we remove this indirection? type Repositories struct { AlbumRepository model.AlbumRepository ArtistRepository model.ArtistRepository @@ -23,6 +24,7 @@ type Repositories struct { NowPlayingRepository model.NowPlayingRepository PlaylistRepository model.PlaylistRepository PropertyRepository model.PropertyRepository + GenreRepository model.GenreRepository } var allProviders = wire.NewSet( @@ -32,7 +34,7 @@ var allProviders = wire.NewSet( api.NewRouter, wire.FieldsOf(new(*Repositories), "AlbumRepository", "ArtistRepository", "CheckSumRepository", "ArtistIndexRepository", "MediaFileRepository", "MediaFolderRepository", "NowPlayingRepository", - "PlaylistRepository", "PropertyRepository"), + "PlaylistRepository", "PropertyRepository", "GenreRepository"), createPersistenceProvider, )