From ea8b3662a5ec915ab3d6f77fe57e37c258cd3526 Mon Sep 17 00:00:00 2001 From: JJ Style Date: Sun, 31 Mar 2024 13:05:22 +0100 Subject: [PATCH] feat(server): RSS feeds for shows and episodes (#20) Fixes: #17 --- README.md | 6 + .../biz/globalplayer/mocks/mock_UseCase.go | 240 ++++++++++++ .../internal/biz/globalplayer/usecase.go | 102 ++++++ .../internal/biz/globalplayer/usecase_test.go | 343 ++++++++++++++++++ .../internal/server/routes.go | 2 + .../internal/service/service.go | 61 +++- .../internal/service/service_test.go | 118 ++++++ go.mod | 1 + go.sum | 2 + pkg/globalplayer/feeds/feeds.go | 31 ++ 10 files changed, 903 insertions(+), 3 deletions(-) create mode 100644 pkg/globalplayer/feeds/feeds.go diff --git a/README.md b/README.md index c4a5013..fd76cdd 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,12 @@ gobal-player-tui ## gobal-player-server RESTful server with friendly APIs to global player, and more features coming soon! +### Features +- RESTful APIs for exploring stations, shows and episodes +- RSS feeds: + - RSS feed for all shows in a station + - RSS feed for all episodes for a given show + ### Docker Run with docker: `docker run --rm -it -p 8080:8080 ghcr.io/jj-style/gobal-player/gobal-player-server:v0.1.11` diff --git a/cmd/gobal-player-server/internal/biz/globalplayer/mocks/mock_UseCase.go b/cmd/gobal-player-server/internal/biz/globalplayer/mocks/mock_UseCase.go index 63b7bc7..74061b0 100644 --- a/cmd/gobal-player-server/internal/biz/globalplayer/mocks/mock_UseCase.go +++ b/cmd/gobal-player-server/internal/biz/globalplayer/mocks/mock_UseCase.go @@ -5,6 +5,8 @@ package mocks import ( context "context" + feeds "github.com/gorilla/feeds" + mock "github.com/stretchr/testify/mock" models "github.com/jj-style/gobal-player/pkg/globalplayer/models" @@ -23,6 +25,65 @@ func (_m *MockUseCase) EXPECT() *MockUseCase_Expecter { return &MockUseCase_Expecter{mock: &_m.Mock} } +// GetAllShowsFeed provides a mock function with given fields: _a0, _a1 +func (_m *MockUseCase) GetAllShowsFeed(_a0 context.Context, _a1 string) (*feeds.Feed, error) { + ret := _m.Called(_a0, _a1) + + if len(ret) == 0 { + panic("no return value specified for GetAllShowsFeed") + } + + var r0 *feeds.Feed + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, string) (*feeds.Feed, error)); ok { + return rf(_a0, _a1) + } + if rf, ok := ret.Get(0).(func(context.Context, string) *feeds.Feed); ok { + r0 = rf(_a0, _a1) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*feeds.Feed) + } + } + + if rf, ok := ret.Get(1).(func(context.Context, string) error); ok { + r1 = rf(_a0, _a1) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// MockUseCase_GetAllShowsFeed_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetAllShowsFeed' +type MockUseCase_GetAllShowsFeed_Call struct { + *mock.Call +} + +// GetAllShowsFeed is a helper method to define mock.On call +// - _a0 context.Context +// - _a1 string +func (_e *MockUseCase_Expecter) GetAllShowsFeed(_a0 interface{}, _a1 interface{}) *MockUseCase_GetAllShowsFeed_Call { + return &MockUseCase_GetAllShowsFeed_Call{Call: _e.mock.On("GetAllShowsFeed", _a0, _a1)} +} + +func (_c *MockUseCase_GetAllShowsFeed_Call) Run(run func(_a0 context.Context, _a1 string)) *MockUseCase_GetAllShowsFeed_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(string)) + }) + return _c +} + +func (_c *MockUseCase_GetAllShowsFeed_Call) Return(_a0 *feeds.Feed, _a1 error) *MockUseCase_GetAllShowsFeed_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *MockUseCase_GetAllShowsFeed_Call) RunAndReturn(run func(context.Context, string) (*feeds.Feed, error)) *MockUseCase_GetAllShowsFeed_Call { + _c.Call.Return(run) + return _c +} + // GetEpisodes provides a mock function with given fields: _a0, _a1, _a2 func (_m *MockUseCase) GetEpisodes(_a0 context.Context, _a1 string, _a2 string) ([]*models.Episode, error) { ret := _m.Called(_a0, _a1, _a2) @@ -83,6 +144,126 @@ func (_c *MockUseCase_GetEpisodes_Call) RunAndReturn(run func(context.Context, s return _c } +// GetEpisodesFeed provides a mock function with given fields: _a0, _a1, _a2 +func (_m *MockUseCase) GetEpisodesFeed(_a0 context.Context, _a1 string, _a2 string) (*feeds.Feed, error) { + ret := _m.Called(_a0, _a1, _a2) + + if len(ret) == 0 { + panic("no return value specified for GetEpisodesFeed") + } + + var r0 *feeds.Feed + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, string, string) (*feeds.Feed, error)); ok { + return rf(_a0, _a1, _a2) + } + if rf, ok := ret.Get(0).(func(context.Context, string, string) *feeds.Feed); ok { + r0 = rf(_a0, _a1, _a2) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*feeds.Feed) + } + } + + if rf, ok := ret.Get(1).(func(context.Context, string, string) error); ok { + r1 = rf(_a0, _a1, _a2) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// MockUseCase_GetEpisodesFeed_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetEpisodesFeed' +type MockUseCase_GetEpisodesFeed_Call struct { + *mock.Call +} + +// GetEpisodesFeed is a helper method to define mock.On call +// - _a0 context.Context +// - _a1 string +// - _a2 string +func (_e *MockUseCase_Expecter) GetEpisodesFeed(_a0 interface{}, _a1 interface{}, _a2 interface{}) *MockUseCase_GetEpisodesFeed_Call { + return &MockUseCase_GetEpisodesFeed_Call{Call: _e.mock.On("GetEpisodesFeed", _a0, _a1, _a2)} +} + +func (_c *MockUseCase_GetEpisodesFeed_Call) Run(run func(_a0 context.Context, _a1 string, _a2 string)) *MockUseCase_GetEpisodesFeed_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(string), args[2].(string)) + }) + return _c +} + +func (_c *MockUseCase_GetEpisodesFeed_Call) Return(_a0 *feeds.Feed, _a1 error) *MockUseCase_GetEpisodesFeed_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *MockUseCase_GetEpisodesFeed_Call) RunAndReturn(run func(context.Context, string, string) (*feeds.Feed, error)) *MockUseCase_GetEpisodesFeed_Call { + _c.Call.Return(run) + return _c +} + +// GetShow provides a mock function with given fields: _a0, _a1, _a2 +func (_m *MockUseCase) GetShow(_a0 context.Context, _a1 string, _a2 string) (*models.Show, error) { + ret := _m.Called(_a0, _a1, _a2) + + if len(ret) == 0 { + panic("no return value specified for GetShow") + } + + var r0 *models.Show + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, string, string) (*models.Show, error)); ok { + return rf(_a0, _a1, _a2) + } + if rf, ok := ret.Get(0).(func(context.Context, string, string) *models.Show); ok { + r0 = rf(_a0, _a1, _a2) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*models.Show) + } + } + + if rf, ok := ret.Get(1).(func(context.Context, string, string) error); ok { + r1 = rf(_a0, _a1, _a2) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// MockUseCase_GetShow_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetShow' +type MockUseCase_GetShow_Call struct { + *mock.Call +} + +// GetShow is a helper method to define mock.On call +// - _a0 context.Context +// - _a1 string +// - _a2 string +func (_e *MockUseCase_Expecter) GetShow(_a0 interface{}, _a1 interface{}, _a2 interface{}) *MockUseCase_GetShow_Call { + return &MockUseCase_GetShow_Call{Call: _e.mock.On("GetShow", _a0, _a1, _a2)} +} + +func (_c *MockUseCase_GetShow_Call) Run(run func(_a0 context.Context, _a1 string, _a2 string)) *MockUseCase_GetShow_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(string), args[2].(string)) + }) + return _c +} + +func (_c *MockUseCase_GetShow_Call) Return(_a0 *models.Show, _a1 error) *MockUseCase_GetShow_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *MockUseCase_GetShow_Call) RunAndReturn(run func(context.Context, string, string) (*models.Show, error)) *MockUseCase_GetShow_Call { + _c.Call.Return(run) + return _c +} + // GetShows provides a mock function with given fields: _a0, _a1 func (_m *MockUseCase) GetShows(_a0 context.Context, _a1 string) ([]*models.Show, error) { ret := _m.Called(_a0, _a1) @@ -142,6 +323,65 @@ func (_c *MockUseCase_GetShows_Call) RunAndReturn(run func(context.Context, stri return _c } +// GetStation provides a mock function with given fields: _a0, _a1 +func (_m *MockUseCase) GetStation(_a0 context.Context, _a1 string) (*models.Station, error) { + ret := _m.Called(_a0, _a1) + + if len(ret) == 0 { + panic("no return value specified for GetStation") + } + + var r0 *models.Station + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, string) (*models.Station, error)); ok { + return rf(_a0, _a1) + } + if rf, ok := ret.Get(0).(func(context.Context, string) *models.Station); ok { + r0 = rf(_a0, _a1) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*models.Station) + } + } + + if rf, ok := ret.Get(1).(func(context.Context, string) error); ok { + r1 = rf(_a0, _a1) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// MockUseCase_GetStation_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetStation' +type MockUseCase_GetStation_Call struct { + *mock.Call +} + +// GetStation is a helper method to define mock.On call +// - _a0 context.Context +// - _a1 string +func (_e *MockUseCase_Expecter) GetStation(_a0 interface{}, _a1 interface{}) *MockUseCase_GetStation_Call { + return &MockUseCase_GetStation_Call{Call: _e.mock.On("GetStation", _a0, _a1)} +} + +func (_c *MockUseCase_GetStation_Call) Run(run func(_a0 context.Context, _a1 string)) *MockUseCase_GetStation_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(string)) + }) + return _c +} + +func (_c *MockUseCase_GetStation_Call) Return(_a0 *models.Station, _a1 error) *MockUseCase_GetStation_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *MockUseCase_GetStation_Call) RunAndReturn(run func(context.Context, string) (*models.Station, error)) *MockUseCase_GetStation_Call { + _c.Call.Return(run) + return _c +} + // GetStations provides a mock function with given fields: _a0 func (_m *MockUseCase) GetStations(_a0 context.Context) ([]*models.Station, error) { ret := _m.Called(_a0) diff --git a/cmd/gobal-player-server/internal/biz/globalplayer/usecase.go b/cmd/gobal-player-server/internal/biz/globalplayer/usecase.go index f39758a..a54511f 100644 --- a/cmd/gobal-player-server/internal/biz/globalplayer/usecase.go +++ b/cmd/gobal-player-server/internal/biz/globalplayer/usecase.go @@ -2,15 +2,25 @@ package globalplayer import ( "context" + "fmt" + "sync/atomic" + "github.com/gorilla/feeds" "github.com/jj-style/gobal-player/pkg/globalplayer" + feeds2 "github.com/jj-style/gobal-player/pkg/globalplayer/feeds" "github.com/jj-style/gobal-player/pkg/globalplayer/models" + "github.com/samber/lo" + "golang.org/x/sync/errgroup" ) type UseCase interface { GetStations(context.Context) ([]*models.Station, error) + GetStation(context.Context, string) (*models.Station, error) GetShows(context.Context, string) ([]*models.Show, error) + GetShow(context.Context, string, string) (*models.Show, error) GetEpisodes(context.Context, string, string) ([]*models.Episode, error) + GetEpisodesFeed(context.Context, string, string) (*feeds.Feed, error) + GetAllShowsFeed(context.Context, string) (*feeds.Feed, error) } type useCase struct { @@ -21,14 +31,106 @@ func (u *useCase) GetStations(ctx context.Context) ([]*models.Station, error) { return u.gp.GetStations() } +func (u *useCase) GetStation(ctx context.Context, stationSlug string) (*models.Station, error) { + stations, err := u.gp.GetStations() + if err != nil { + return nil, err + } + + got, found := lo.Find(stations, func(item *models.Station) bool { return item.Slug == stationSlug }) + if !found { + return nil, fmt.Errorf("station %s not found", stationSlug) + } + return got, nil +} + func (u *useCase) GetShows(ctx context.Context, stationSlug string) ([]*models.Show, error) { return u.gp.GetShows(stationSlug) } +func (u *useCase) GetShow(ctx context.Context, stationSlug, showId string) (*models.Show, error) { + shows, err := u.gp.GetShows(stationSlug) + if err != nil { + return nil, err + } + + got, found := lo.Find(shows, func(item *models.Show) bool { return item.Id == showId }) + if !found { + return nil, fmt.Errorf("show id=%s in station %s not found", showId, stationSlug) + } + return got, nil +} + func (u *useCase) GetEpisodes(ctx context.Context, stationSlug, showId string) ([]*models.Episode, error) { return u.gp.GetEpisodes(stationSlug, showId) } +func (u *useCase) GetEpisodesFeed(ctx context.Context, stationsSlug, showId string) (*feeds.Feed, error) { + eps, err := u.GetEpisodes(ctx, stationsSlug, showId) + if err != nil { + return nil, err + } + + show, err := u.GetShow(ctx, stationsSlug, showId) + if err != nil { + return nil, err + } + + return feeds2.ToFeed(show, eps, eps[0].Description), nil +} + +func (u *useCase) GetAllShowsFeed(ctx context.Context, stationsSlug string) (*feeds.Feed, error) { + st, err := u.GetStation(ctx, stationsSlug) + if err != nil { + return nil, err + } + + shows, err := u.GetShows(ctx, stationsSlug) + if err != nil { + return nil, err + } + + episodes := make([]*models.Episode, 0) + epsChan := make(chan *models.Episode) + nshows := int32(len(shows)) + + // load eps for all shows concurrently + var g errgroup.Group + for _, show := range shows { + show := show + g.Go(func() error { + // last one out closes shop + defer func() { + if atomic.AddInt32(&nshows, -1) == 0 { + close(epsChan) + } + }() + + // fetch episodes and add to channel + eps, err := u.GetEpisodes(ctx, stationsSlug, show.Id) + if err != nil { + return err + } + for _, ep := range eps { + epsChan <- ep + } + return nil + }) + } + g.Go(func() error { + for ep := range epsChan { + episodes = append(episodes, ep) + } + return nil + }) + + if err := g.Wait(); err != nil { + return nil, err + } + + return feeds2.ToFeed(&models.Show{Name: st.Name, ImageUrl: st.Imageurl}, episodes, st.Tagline), nil +} + func NewUseCase(gp globalplayer.GlobalPlayer) UseCase { return &useCase{ gp: gp, diff --git a/cmd/gobal-player-server/internal/biz/globalplayer/usecase_test.go b/cmd/gobal-player-server/internal/biz/globalplayer/usecase_test.go index fea9d08..25731b9 100644 --- a/cmd/gobal-player-server/internal/biz/globalplayer/usecase_test.go +++ b/cmd/gobal-player-server/internal/biz/globalplayer/usecase_test.go @@ -3,8 +3,11 @@ package globalplayer_test import ( "context" "errors" + "slices" + "strings" "testing" + "github.com/gorilla/feeds" "github.com/jj-style/gobal-player/cmd/gobal-player-server/internal/biz/globalplayer" gpMocks "github.com/jj-style/gobal-player/pkg/globalplayer/mocks" "github.com/jj-style/gobal-player/pkg/globalplayer/models" @@ -191,3 +194,343 @@ func Test_useCase_GetEpisodes(t *testing.T) { }) } } + +func Test_useCase_GetStation(t *testing.T) { + var ctx = context.Background() + type fields struct { + gp *gpMocks.MockGlobalPlayer + } + type args struct { + stationSlug string + } + tests := []struct { + name string + setup func(f *fields) + args args + want *models.Station + wantErr assert.ErrorAssertionFunc + }{ + { + name: "happy", + setup: func(f *fields) { + f.gp.EXPECT(). + GetStations(). + Return([]*models.Station{{Id: "a", Name: "a", Slug: "a"}, {Id: "b", Name: "b", Slug: "b"}}, nil) + }, + args: args{stationSlug: "b"}, + want: &models.Station{Id: "b", Name: "b", Slug: "b"}, + wantErr: assert.NoError, + }, + { + name: "error getting stations", + setup: func(f *fields) { + f.gp.EXPECT(). + GetStations(). + Return(nil, errors.New("boom")) + }, + args: args{stationSlug: "a"}, + want: nil, + wantErr: assert.Error, + }, + { + name: "station not found", + setup: func(f *fields) { + f.gp.EXPECT(). + GetStations(). + Return([]*models.Station{{Id: "a", Name: "a", Slug: "a"}}, nil) + }, + args: args{stationSlug: "b"}, + want: nil, + wantErr: assert.Error, + }, + } + for _, tt := range tests { + f := &fields{ + gp: gpMocks.NewMockGlobalPlayer(t), + } + if tt.setup != nil { + tt.setup(f) + } + t.Run(tt.name, func(t *testing.T) { + u := globalplayer.NewUseCase(f.gp) + got, err := u.GetStation(ctx, tt.args.stationSlug) + tt.wantErr(t, err) + assert.Equal(t, tt.want, got) + }) + } +} + +func Test_useCase_GetShow(t *testing.T) { + var ctx = context.Background() + type fields struct { + gp *gpMocks.MockGlobalPlayer + } + type args struct { + stationSlug string + showId string + } + tests := []struct { + name string + setup func(f *fields) + args args + want *models.Show + wantErr assert.ErrorAssertionFunc + }{ + { + name: "happy", + setup: func(f *fields) { + f.gp.EXPECT(). + GetShows("station"). + Return([]*models.Show{{Id: "a", Name: "a"}, {Id: "b", Name: "b"}}, nil) + }, + args: args{stationSlug: "station", showId: "b"}, + want: &models.Show{Id: "b", Name: "b"}, + wantErr: assert.NoError, + }, + { + name: "error getting shows", + setup: func(f *fields) { + f.gp.EXPECT(). + GetShows("station"). + Return(nil, errors.New("boom")) + }, + args: args{stationSlug: "station", showId: "b"}, + want: nil, + wantErr: assert.Error, + }, + { + name: "show not found", + setup: func(f *fields) { + f.gp.EXPECT(). + GetShows("station"). + Return([]*models.Show{{Id: "a", Name: "a"}}, nil) + }, + args: args{stationSlug: "station", showId: "b"}, + want: nil, + wantErr: assert.Error, + }, + } + for _, tt := range tests { + f := &fields{ + gp: gpMocks.NewMockGlobalPlayer(t), + } + if tt.setup != nil { + tt.setup(f) + } + t.Run(tt.name, func(t *testing.T) { + u := globalplayer.NewUseCase(f.gp) + got, err := u.GetShow(ctx, tt.args.stationSlug, tt.args.showId) + tt.wantErr(t, err) + assert.Equal(t, tt.want, got) + }) + } +} + +func Test_useCase_GetEpisodesFeed(t *testing.T) { + var ctx = context.Background() + type fields struct { + gp *gpMocks.MockGlobalPlayer + } + type args struct { + stationSlug string + showId string + } + tests := []struct { + name string + setup func(f *fields) + args args + want *feeds.Feed + wantErr assert.ErrorAssertionFunc + }{ + { + name: "happy", + args: args{stationSlug: "station", showId: "show"}, + setup: func(f *fields) { + f.gp.EXPECT().GetEpisodes("station", "show"). + Return([]*models.Episode{{Id: "id", Name: "episode 1", Description: "episode", StreamUrl: "episode.mp3"}}, nil) + + f.gp.EXPECT(). + GetShows("station"). + Return([]*models.Show{{Id: "show", Name: "show", ImageUrl: "show.jpg"}}, nil) + }, + want: &feeds.Feed{ + Title: "show", + Description: "episode", + Image: &feeds.Image{Url: "show.jpg"}, + Items: []*feeds.Item{ + { + Id: "id", + Title: "episode 1: Monday 01 January 0001", + Description: "episode

Available until Monday 01 January 0001 00:00:00.", + Enclosure: &feeds.Enclosure{Url: "episode.mp3", Type: "audio/mpeg", Length: "1"}, + Link: &feeds.Link{Href: "episode.mp3"}, + }, + }, + }, + wantErr: assert.NoError, + }, + { + name: "error getting episodes", + args: args{stationSlug: "station", showId: "show"}, + setup: func(f *fields) { + f.gp.EXPECT().GetEpisodes("station", "show"). + Return(nil, errors.New("boom")) + }, + want: nil, + wantErr: assert.Error, + }, + { + name: "error getting show", + args: args{stationSlug: "station", showId: "show"}, + setup: func(f *fields) { + f.gp.EXPECT().GetEpisodes("station", "show"). + Return([]*models.Episode{{Id: "id", Name: "episode 1", Description: "episode", StreamUrl: "episode.mp3"}}, nil) + + f.gp.EXPECT(). + GetShows("station"). + Return(nil, errors.New("boom")) + }, + want: nil, + wantErr: assert.Error, + }, + } + for _, tt := range tests { + f := &fields{ + gp: gpMocks.NewMockGlobalPlayer(t), + } + if tt.setup != nil { + tt.setup(f) + } + t.Run(tt.name, func(t *testing.T) { + u := globalplayer.NewUseCase(f.gp) + got, err := u.GetEpisodesFeed(ctx, tt.args.stationSlug, tt.args.showId) + tt.wantErr(t, err) + assert.Equal(t, tt.want, got) + }) + } +} + +func Test_useCase_GetAllShowsFeed(t *testing.T) { + var ctx = context.Background() + type fields struct { + gp *gpMocks.MockGlobalPlayer + } + type args struct { + stationSlug string + } + tests := []struct { + name string + setup func(f *fields) + args args + want *feeds.Feed + wantErr assert.ErrorAssertionFunc + }{ + { + name: "happy", + args: args{stationSlug: "station"}, + setup: func(f *fields) { + f.gp.EXPECT().GetStations().Return([]*models.Station{{Name: "station", Slug: "station", Tagline: "a cool station", Imageurl: "station.jpg"}}, nil) + + f.gp.EXPECT(). + GetShows("station"). + Return([]*models.Show{ + {Id: "show1", Name: "show1"}, + {Id: "show2", Name: "show2"}, + }, nil) + + f.gp.EXPECT().GetEpisodes("station", "show1"). + Return([]*models.Episode{{Id: "show1id1", Name: "show 1 episode 1", Description: "show 1 episode 1", StreamUrl: "s1ep1.mp3"}}, nil) + + f.gp.EXPECT().GetEpisodes("station", "show2"). + Return([]*models.Episode{{Id: "show2id1", Name: "show 2 episode 1", Description: "show 2 episode 1", StreamUrl: "s2ep1.mp3"}}, nil) + + }, + want: &feeds.Feed{ + Title: "station", + Description: "a cool station", + Image: &feeds.Image{Url: "station.jpg"}, + Items: []*feeds.Item{ + { + Id: "show1id1", + Title: "show 1 episode 1: Monday 01 January 0001", + Description: "show 1 episode 1

Available until Monday 01 January 0001 00:00:00.", + Enclosure: &feeds.Enclosure{Url: "s1ep1.mp3", Type: "audio/mpeg", Length: "1"}, + Link: &feeds.Link{Href: "s1ep1.mp3"}, + }, + { + Id: "show2id1", + Title: "show 2 episode 1: Monday 01 January 0001", + Description: "show 2 episode 1

Available until Monday 01 January 0001 00:00:00.", + Enclosure: &feeds.Enclosure{Url: "s2ep1.mp3", Type: "audio/mpeg", Length: "1"}, + Link: &feeds.Link{Href: "s2ep1.mp3"}, + }, + }, + }, + wantErr: assert.NoError, + }, + { + name: "error getting episodes", + args: args{stationSlug: "station"}, + setup: func(f *fields) { + f.gp.EXPECT().GetStations().Return([]*models.Station{{Name: "station", Slug: "station", Tagline: "a cool station", Imageurl: "station.jpg"}}, nil) + + f.gp.EXPECT(). + GetShows("station"). + Return([]*models.Show{ + {Id: "show1", Name: "show1"}, + {Id: "show2", Name: "show2"}, + }, nil) + + f.gp.EXPECT().GetEpisodes("station", "show1"). + Return(nil, errors.New("boom")) + + f.gp.EXPECT().GetEpisodes("station", "show2"). + Return([]*models.Episode{{Id: "show2id1", Name: "show 2 episode 1", Description: "show 2 episode 1", StreamUrl: "s2ep1.mp3"}}, nil).Maybe() + + }, + want: nil, + wantErr: assert.Error, + }, + { + name: "error getting shows", + args: args{stationSlug: "station"}, + setup: func(f *fields) { + f.gp.EXPECT().GetStations().Return([]*models.Station{{Name: "station", Slug: "station", Tagline: "a cool station", Imageurl: "station.jpg"}}, nil) + + f.gp.EXPECT(). + GetShows("station"). + Return(nil, errors.New("boom")) + + }, + want: nil, + wantErr: assert.Error, + }, + { + name: "error getting station", + args: args{stationSlug: "station"}, + setup: func(f *fields) { + f.gp.EXPECT().GetStations().Return(nil, errors.New("boom")) + }, + want: nil, + wantErr: assert.Error, + }, + } + for _, tt := range tests { + f := &fields{ + gp: gpMocks.NewMockGlobalPlayer(t), + } + if tt.setup != nil { + tt.setup(f) + } + t.Run(tt.name, func(t *testing.T) { + u := globalplayer.NewUseCase(f.gp) + got, err := u.GetAllShowsFeed(ctx, tt.args.stationSlug) + tt.wantErr(t, err) + if tt.want != nil { + + slices.SortFunc(got.Items, func(a, b *feeds.Item) int { return strings.Compare(a.Id, b.Id) }) + assert.Equal(t, tt.want, got) + } + }) + } +} diff --git a/cmd/gobal-player-server/internal/server/routes.go b/cmd/gobal-player-server/internal/server/routes.go index 7ce10a3..ecbec5f 100644 --- a/cmd/gobal-player-server/internal/server/routes.go +++ b/cmd/gobal-player-server/internal/server/routes.go @@ -9,4 +9,6 @@ func addRoutes(r *gin.Engine, service *service.Service) { r.GET("/stations", service.GetStations) r.GET("/shows/:slug", service.GetShows) r.GET("/episodes/:slug/:id", service.GetEpisodes) + r.GET("/shows/:slug/rss", service.GetAllShowsRss) + r.GET("/episodes/:slug/:id/rss", service.GetEpisodesRss) } diff --git a/cmd/gobal-player-server/internal/service/service.go b/cmd/gobal-player-server/internal/service/service.go index 657c7a0..e2bc4cb 100644 --- a/cmd/gobal-player-server/internal/service/service.go +++ b/cmd/gobal-player-server/internal/service/service.go @@ -12,7 +12,7 @@ type Service struct { } func (s *Service) GetStations(c *gin.Context) { - stations, err := s.uc.GetStations(c.Request.Context()) + stations, err := s.uc.GetStations(c) if err != nil { c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return @@ -32,7 +32,7 @@ func (s *Service) GetShows(c *gin.Context) { return } - shows, err := s.uc.GetShows(c.Request.Context(), req.Slug) + shows, err := s.uc.GetShows(c, req.Slug) if err != nil { c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return @@ -53,7 +53,7 @@ func (s *Service) GetEpisodes(c *gin.Context) { return } - eps, err := s.uc.GetEpisodes(c.Request.Context(), req.Slug, req.Id) + eps, err := s.uc.GetEpisodes(c, req.Slug, req.Id) if err != nil { c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return @@ -62,6 +62,61 @@ func (s *Service) GetEpisodes(c *gin.Context) { c.JSONP(http.StatusOK, gin.H{"episodes": eps}) } +func (s *Service) GetEpisodesRss(c *gin.Context) { + type request struct { + Slug string `uri:"slug" binding:"required"` + Id string `uri:"id" binding:"required"` + } + + var req request + if err := c.ShouldBindUri(&req); err != nil { + c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + feed, err := s.uc.GetEpisodesFeed(c, req.Slug, req.Id) + if err != nil { + c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + rss, err := feed.ToRss() + if err != nil { + c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.Writer.Header().Set("content-type", "application/xml") + c.String(200, rss) +} + +func (s *Service) GetAllShowsRss(c *gin.Context) { + type request struct { + Slug string `uri:"slug" binding:"required"` + } + + var req request + if err := c.ShouldBindUri(&req); err != nil { + c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + feed, err := s.uc.GetAllShowsFeed(c, req.Slug) + if err != nil { + c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + rss, err := feed.ToRss() + if err != nil { + c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.Writer.Header().Set("content-type", "application/xml") + c.String(200, rss) +} + func NewService(uc globalplayer.UseCase) *Service { return &Service{uc: uc} } diff --git a/cmd/gobal-player-server/internal/service/service_test.go b/cmd/gobal-player-server/internal/service/service_test.go index 53b46cb..4fd9e2b 100644 --- a/cmd/gobal-player-server/internal/service/service_test.go +++ b/cmd/gobal-player-server/internal/service/service_test.go @@ -9,6 +9,7 @@ import ( "testing" "github.com/gin-gonic/gin" + "github.com/gorilla/feeds" gpMocks "github.com/jj-style/gobal-player/cmd/gobal-player-server/internal/biz/globalplayer/mocks" "github.com/jj-style/gobal-player/cmd/gobal-player-server/internal/server" "github.com/jj-style/gobal-player/cmd/gobal-player-server/internal/service" @@ -210,6 +211,123 @@ func Test_service_GetEpisodes(t *testing.T) { } } +func Test_service_GetEpisodesRss(t *testing.T) { + type fields struct { + uc *gpMocks.MockUseCase + } + type args struct { + slug string + id string + } + tests := []struct { + name string + args args + setup func(f fields) + wantCode int + wantContentType string + }{ + { + name: "happy", + args: args{slug: "slug", id: "id"}, + setup: func(f fields) { + f.uc.EXPECT().GetEpisodesFeed(mock.Anything, "slug", "id"). + Return(&feeds.Feed{Title: "title", Description: "description", Id: "id"}, nil) + }, + wantCode: http.StatusOK, + wantContentType: "application/xml", + }, + { + name: "unhappy", + args: args{slug: "slug", id: "id"}, + setup: func(f fields) { + f.uc.EXPECT().GetEpisodesFeed(mock.Anything, "slug", "id"). + Return(nil, errors.New("boom")) + }, + wantCode: http.StatusInternalServerError, + wantContentType: "application/json; charset=utf-8", + }, + } + for _, tt := range tests { + f := fields{ + uc: gpMocks.NewMockUseCase(t), + } + t.Run(tt.name, func(t *testing.T) { + if tt.setup != nil { + tt.setup(f) + } + + router := givenService(f.uc) + + w := httptest.NewRecorder() + request, err := http.NewRequest(http.MethodGet, fmt.Sprintf("/episodes/%s/%s/rss", tt.args.slug, tt.args.id), nil) + assert.NoError(t, err) + + router.ServeHTTP(w, request) + + assert.Equal(t, tt.wantCode, w.Code) + assert.Equal(t, tt.wantContentType, w.Header().Get("content-type")) + }) + } +} + +func Test_service_GetAllShowsRss(t *testing.T) { + type fields struct { + uc *gpMocks.MockUseCase + } + type args struct { + slug string + } + tests := []struct { + name string + args args + setup func(f fields) + wantCode int + wantContentType string + }{ + { + name: "happy", + args: args{slug: "slug"}, + setup: func(f fields) { + f.uc.EXPECT().GetAllShowsFeed(mock.Anything, "slug"). + Return(&feeds.Feed{Title: "title", Description: "description", Id: "id"}, nil) + }, + wantCode: http.StatusOK, + wantContentType: "application/xml", + }, + { + name: "unhappy", + args: args{slug: "slug"}, + setup: func(f fields) { + f.uc.EXPECT().GetAllShowsFeed(mock.Anything, "slug"). + Return(nil, errors.New("boom")) + }, + wantCode: http.StatusInternalServerError, + wantContentType: "application/json; charset=utf-8", + }, + } + for _, tt := range tests { + f := fields{ + uc: gpMocks.NewMockUseCase(t), + } + t.Run(tt.name, func(t *testing.T) { + if tt.setup != nil { + tt.setup(f) + } + + router := givenService(f.uc) + + w := httptest.NewRecorder() + request, err := http.NewRequest(http.MethodGet, fmt.Sprintf("/shows/%s/rss", tt.args.slug), nil) + assert.NoError(t, err) + + router.ServeHTTP(w, request) + + assert.Equal(t, tt.wantCode, w.Code) + assert.Equal(t, tt.wantContentType, w.Header().Get("content-type")) + }) + } +} + func givenService(uc *gpMocks.MockUseCase) *gin.Engine { s := service.NewService(uc) srv := server.NewServer(s) diff --git a/go.mod b/go.mod index 36ebea6..74d76aa 100644 --- a/go.mod +++ b/go.mod @@ -9,6 +9,7 @@ require ( github.com/gdamore/tcell/v2 v2.7.4 github.com/gin-gonic/gin v1.9.1 github.com/golang/mock v1.6.0 + github.com/gorilla/feeds v1.1.2 github.com/patrickmn/go-cache v2.1.0+incompatible github.com/rivo/tview v0.0.0-20240225120200-5605142ca62e github.com/samber/lo v1.39.0 diff --git a/go.sum b/go.sum index d60757e..a3dec88 100644 --- a/go.sum +++ b/go.sum @@ -76,6 +76,8 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/wire v0.6.0 h1:HBkoIh4BdSxoyo9PveV8giw7ZsaBOvzWKfcg/6MrVwI= github.com/google/wire v0.6.0/go.mod h1:F4QhpQ9EDIdJ1Mbop/NZBRB+5yrR6qg3BnctaoUk6NA= +github.com/gorilla/feeds v1.1.2 h1:pxzZ5PD3RJdhFH2FsJJ4x6PqMqbgFk1+Vez4XWBW8Iw= +github.com/gorilla/feeds v1.1.2/go.mod h1:WMib8uJP3BbY+X8Szd1rA5Pzhdfh+HCCAYT2z7Fza6Y= github.com/hashicorp/hcl v1.0.1-vault-5 h1:kI3hhbbyzr4dldA8UdTb7ZlVVlI2DACdCfz31RPDgJM= github.com/hashicorp/hcl v1.0.1-vault-5/go.mod h1:XYhtn6ijBSAj6n4YqAaf7RBPS4I06AItNorpy+MoQNM= github.com/influxdata/influxdb-client-go/v2 v2.13.0 h1:ioBbLmR5NMbAjP4UVA5r9b5xGjpABD7j65pI8kFphDM= diff --git a/pkg/globalplayer/feeds/feeds.go b/pkg/globalplayer/feeds/feeds.go new file mode 100644 index 0000000..f9439ba --- /dev/null +++ b/pkg/globalplayer/feeds/feeds.go @@ -0,0 +1,31 @@ +package feeds + +import ( + "fmt" + + "github.com/gorilla/feeds" + "github.com/jj-style/gobal-player/pkg/globalplayer/models" + "github.com/samber/lo" +) + +// Given a show and it's episodes, return a feed to consume the show. +func ToFeed(show *models.Show, episodes []*models.Episode, description string) *feeds.Feed { + feed := &feeds.Feed{ + Title: show.Name, + Image: &feeds.Image{Url: show.ImageUrl}, + Updated: lo.MaxBy(episodes, func(a, b *models.Episode) bool { return b.Aired.After(a.Aired) }).Aired, + Description: description, + } + feed.Items = lo.Map(episodes, func(item *models.Episode, _ int) *feeds.Item { + return &feeds.Item{ + Title: fmt.Sprintf("%s: %s", item.Name, item.Aired.Format("Monday 02 January 2006")), + Link: &feeds.Link{Href: item.StreamUrl}, + Id: item.Id, + Created: item.Aired, + Description: fmt.Sprintf("%s

Available until %s.", item.Description, item.Until.Format("Monday 02 January 2006 15:04:05")), + Enclosure: &feeds.Enclosure{Url: item.StreamUrl, Type: "audio/mpeg", Length: "1"}, + } + }) + + return feed +}