From 8877b1695a440a7155a66ab30ce1ec628692800d Mon Sep 17 00:00:00 2001 From: Kendall Garner <17521368+kgarner7@users.noreply.github.com> Date: Sun, 15 Jan 2023 20:11:37 +0000 Subject: [PATCH] Add Internet Radio support (#2063) * add internet radio support * Add dynamic sidebar icon to Radios * Fix typos * Make URL suffix consistent * Fix typo * address feedback * Don't need to preload when playing Internet Radios * Reorder migration, or else it won't be applied * Make Radio list view responsive Also added filter by name, removed RadioActions and RadioContextMenu, and added a default radio icon, in case of favicon is not available. * Simplify StreamField usage * fix button, hide progress on mobile * use js styles over index.css Co-authored-by: Deluan --- .../20230115103212_create_internet_radio.go | 30 +++ model/datastore.go | 1 + model/radio.go | 23 +++ persistence/persistence.go | 6 + persistence/persistence_suite_test.go | 17 +- persistence/radio_repository.go | 142 ++++++++++++++ persistence/radio_repository_test.go | 176 ++++++++++++++++++ server/nativeapi/native_api.go | 1 + server/subsonic/api.go | 8 +- server/subsonic/radio.go | 108 +++++++++++ ...RadioStations with data should match .JSON | 1 + ...tRadioStations with data should match .XML | 1 + ...ioStations without data should match .JSON | 1 + ...dioStations without data should match .XML | 1 + server/subsonic/responses/responses.go | 13 ++ server/subsonic/responses/responses_test.go | 35 ++++ tests/mock_persistence.go | 8 + tests/mock_radio_repository.go | 85 +++++++++ ui/public/internet-radio-icon.svg | 1 + ui/src/App.js | 5 + ui/src/audioplayer/AudioTitle.js | 9 +- ui/src/audioplayer/Player.js | 15 +- ui/src/audioplayer/PlayerToolbar.js | 3 +- ui/src/audioplayer/styles.js | 11 ++ ui/src/i18n/en.json | 25 ++- ui/src/radio/DeleteRadioButton.js | 76 ++++++++ ui/src/radio/RadioCreate.js | 60 ++++++ ui/src/radio/RadioEdit.js | 134 +++++++++++++ ui/src/radio/RadioList.js | 139 ++++++++++++++ ui/src/radio/RadioShow.js | 52 ++++++ ui/src/radio/StreamField.js | 50 +++++ ui/src/radio/helper.js | 35 ++++ ui/src/radio/index.js | 28 +++ ui/src/reducers/playerReducer.js | 13 ++ 34 files changed, 1304 insertions(+), 9 deletions(-) create mode 100644 db/migration/20230115103212_create_internet_radio.go create mode 100644 model/radio.go create mode 100644 persistence/radio_repository.go create mode 100644 persistence/radio_repository_test.go create mode 100644 server/subsonic/radio.go create mode 100644 server/subsonic/responses/.snapshots/Responses InternetRadioStations with data should match .JSON create mode 100644 server/subsonic/responses/.snapshots/Responses InternetRadioStations with data should match .XML create mode 100644 server/subsonic/responses/.snapshots/Responses InternetRadioStations without data should match .JSON create mode 100644 server/subsonic/responses/.snapshots/Responses InternetRadioStations without data should match .XML create mode 100644 tests/mock_radio_repository.go create mode 100644 ui/public/internet-radio-icon.svg create mode 100644 ui/src/radio/DeleteRadioButton.js create mode 100644 ui/src/radio/RadioCreate.js create mode 100644 ui/src/radio/RadioEdit.js create mode 100644 ui/src/radio/RadioList.js create mode 100644 ui/src/radio/RadioShow.js create mode 100644 ui/src/radio/StreamField.js create mode 100644 ui/src/radio/helper.js create mode 100644 ui/src/radio/index.js diff --git a/db/migration/20230115103212_create_internet_radio.go b/db/migration/20230115103212_create_internet_radio.go new file mode 100644 index 00000000..b8575fc5 --- /dev/null +++ b/db/migration/20230115103212_create_internet_radio.go @@ -0,0 +1,30 @@ +package migrations + +import ( + "database/sql" + + "github.com/pressly/goose" +) + +func init() { + goose.AddMigration(upCreateInternetRadio, downCreateInternetRadio) +} + +func upCreateInternetRadio(tx *sql.Tx) error { + _, err := tx.Exec(` +create table if not exists radio +( + id varchar(255) not null primary key, + name varchar not null unique, + stream_url varchar not null, + home_page_url varchar default '' not null, + created_at datetime, + updated_at datetime +); +`) + return err +} + +func downCreateInternetRadio(tx *sql.Tx) error { + return nil +} diff --git a/model/datastore.go b/model/datastore.go index 19a08c05..844c70d6 100644 --- a/model/datastore.go +++ b/model/datastore.go @@ -29,6 +29,7 @@ type DataStore interface { PlayQueue(ctx context.Context) PlayQueueRepository Transcoding(ctx context.Context) TranscodingRepository Player(ctx context.Context) PlayerRepository + Radio(ctx context.Context) RadioRepository Share(ctx context.Context) ShareRepository Property(ctx context.Context) PropertyRepository User(ctx context.Context) UserRepository diff --git a/model/radio.go b/model/radio.go new file mode 100644 index 00000000..b0706577 --- /dev/null +++ b/model/radio.go @@ -0,0 +1,23 @@ +package model + +import "time" + +type Radio struct { + ID string `structs:"id" json:"id" orm:"pk;column(id)"` + StreamUrl string `structs:"stream_url" json:"streamUrl"` + Name string `structs:"name" json:"name"` + HomePageUrl string `structs:"home_page_url" json:"homePageUrl" orm:"column(home_page_url)"` + CreatedAt time.Time `structs:"created_at" json:"createdAt"` + UpdatedAt time.Time `structs:"updated_at" json:"updatedAt"` +} + +type Radios []Radio + +type RadioRepository interface { + ResourceRepository + CountAll(options ...QueryOptions) (int64, error) + Delete(id string) error + Get(id string) (*Radio, error) + GetAll(options ...QueryOptions) (Radios, error) + Put(u *Radio) error +} diff --git a/persistence/persistence.go b/persistence/persistence.go index c9c99fc6..277cd965 100644 --- a/persistence/persistence.go +++ b/persistence/persistence.go @@ -52,6 +52,10 @@ func (s *SQLStore) Property(ctx context.Context) model.PropertyRepository { return NewPropertyRepository(ctx, s.getOrmer()) } +func (s *SQLStore) Radio(ctx context.Context) model.RadioRepository { + return NewRadioRepository(ctx, s.getOrmer()) +} + func (s *SQLStore) UserProps(ctx context.Context) model.UserPropsRepository { return NewUserPropsRepository(ctx, s.getOrmer()) } @@ -94,6 +98,8 @@ func (s *SQLStore) Resource(ctx context.Context, m interface{}) model.ResourceRe return s.Genre(ctx).(model.ResourceRepository) case model.Playlist: return s.Playlist(ctx).(model.ResourceRepository) + case model.Radio: + return s.Radio(ctx).(model.ResourceRepository) case model.Share: return s.Share(ctx).(model.ResourceRepository) } diff --git a/persistence/persistence_suite_test.go b/persistence/persistence_suite_test.go index b2d5016d..21267121 100644 --- a/persistence/persistence_suite_test.go +++ b/persistence/persistence_suite_test.go @@ -69,6 +69,12 @@ var ( } ) +var ( + radioWithoutHomePage = model.Radio{ID: "1235", StreamUrl: "https://example.com:8000/1/stream.mp3", HomePageUrl: "", Name: "No Homepage"} + radioWithHomePage = model.Radio{ID: "5010", StreamUrl: "https://example.com/stream.mp3", Name: "Example Radio", HomePageUrl: "https://example.com"} + testRadios = model.Radios{radioWithoutHomePage, radioWithHomePage} +) + var ( plsBest model.Playlist plsCool model.Playlist @@ -84,7 +90,7 @@ func P(path string) string { var _ = BeforeSuite(func() { o := orm.NewOrm() ctx := log.NewContext(context.TODO()) - user := model.User{ID: "userid", UserName: "userid"} + user := model.User{ID: "userid", UserName: "userid", IsAdmin: true} ctx = request.WithUser(ctx, user) ur := NewUserRepository(ctx, o) @@ -129,6 +135,15 @@ var _ = BeforeSuite(func() { } } + rar := NewRadioRepository(ctx, o) + for i := range testRadios { + r := testRadios[i] + err := rar.Put(&r) + if err != nil { + panic(err) + } + } + plsBest = model.Playlist{ Name: "Best", Comment: "No Comments", diff --git a/persistence/radio_repository.go b/persistence/radio_repository.go new file mode 100644 index 00000000..17e4d249 --- /dev/null +++ b/persistence/radio_repository.go @@ -0,0 +1,142 @@ +package persistence + +import ( + "context" + "errors" + "strings" + "time" + + . "github.com/Masterminds/squirrel" + "github.com/beego/beego/v2/client/orm" + "github.com/deluan/rest" + "github.com/google/uuid" + "github.com/navidrome/navidrome/model" +) + +type radioRepository struct { + sqlRepository + sqlRestful +} + +func NewRadioRepository(ctx context.Context, o orm.QueryExecutor) model.RadioRepository { + r := &radioRepository{} + r.ctx = ctx + r.ormer = o + r.tableName = "radio" + r.filterMappings = map[string]filterFunc{ + "name": containsFilter, + } + return r +} + +func (r *radioRepository) isPermitted() bool { + user := loggedUser(r.ctx) + return user.IsAdmin +} + +func (r *radioRepository) CountAll(options ...model.QueryOptions) (int64, error) { + sql := r.newSelect(options...) + return r.count(sql, options...) +} + +func (r *radioRepository) Delete(id string) error { + if !r.isPermitted() { + return rest.ErrPermissionDenied + } + + return r.delete(Eq{"id": id}) +} + +func (r *radioRepository) Get(id string) (*model.Radio, error) { + sel := r.newSelect().Where(Eq{"id": id}).Columns("*") + res := model.Radio{} + err := r.queryOne(sel, &res) + return &res, err +} + +func (r *radioRepository) GetAll(options ...model.QueryOptions) (model.Radios, error) { + sel := r.newSelect(options...).Columns("*") + res := model.Radios{} + err := r.queryAll(sel, &res) + return res, err +} + +func (r *radioRepository) Put(radio *model.Radio) error { + if !r.isPermitted() { + return rest.ErrPermissionDenied + } + + var values map[string]interface{} + + radio.UpdatedAt = time.Now() + + if radio.ID == "" { + radio.CreatedAt = time.Now() + radio.ID = strings.ReplaceAll(uuid.NewString(), "-", "") + values, _ = toSqlArgs(*radio) + } else { + values, _ = toSqlArgs(*radio) + update := Update(r.tableName).Where(Eq{"id": radio.ID}).SetMap(values) + count, err := r.executeSQL(update) + + if err != nil { + return err + } else if count > 0 { + return nil + } + } + + values["created_at"] = time.Now() + insert := Insert(r.tableName).SetMap(values) + _, err := r.executeSQL(insert) + return err +} + +func (r *radioRepository) Count(options ...rest.QueryOptions) (int64, error) { + return r.CountAll(r.parseRestOptions(options...)) +} + +func (r *radioRepository) EntityName() string { + return "radio" +} + +func (r *radioRepository) NewInstance() interface{} { + return &model.Radio{} +} + +func (r *radioRepository) Read(id string) (interface{}, error) { + return r.Get(id) +} + +func (r *radioRepository) ReadAll(options ...rest.QueryOptions) (interface{}, error) { + return r.GetAll(r.parseRestOptions(options...)) +} + +func (r *radioRepository) Save(entity interface{}) (string, error) { + t := entity.(*model.Radio) + if !r.isPermitted() { + return "", rest.ErrPermissionDenied + } + err := r.Put(t) + if errors.Is(err, model.ErrNotFound) { + return "", rest.ErrNotFound + } + return t.ID, err +} + +func (r *radioRepository) Update(id string, entity interface{}, cols ...string) error { + t := entity.(*model.Radio) + t.ID = id + if !r.isPermitted() { + return rest.ErrPermissionDenied + } + err := r.Put(t) + if errors.Is(err, model.ErrNotFound) { + return rest.ErrNotFound + } + return err +} + +var _ model.RadioRepository = (*radioRepository)(nil) +var _ rest.Repository = (*radioRepository)(nil) +var _ rest.Persistable = (*radioRepository)(nil) diff --git a/persistence/radio_repository_test.go b/persistence/radio_repository_test.go new file mode 100644 index 00000000..b87b1fc1 --- /dev/null +++ b/persistence/radio_repository_test.go @@ -0,0 +1,176 @@ +package persistence + +import ( + "context" + + "github.com/beego/beego/v2/client/orm" + "github.com/deluan/rest" + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/request" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var ( + NewId string = "123-456-789" +) + +var _ = Describe("RadioRepository", func() { + var repo model.RadioRepository + + Describe("Admin User", func() { + BeforeEach(func() { + ctx := log.NewContext(context.TODO()) + ctx = request.WithUser(ctx, model.User{ID: "userid", UserName: "userid", IsAdmin: true}) + repo = NewRadioRepository(ctx, orm.NewOrm()) + _ = repo.Put(&radioWithHomePage) + }) + + AfterEach(func() { + all, _ := repo.GetAll() + + for _, radio := range all { + _ = repo.Delete(radio.ID) + } + + for i := range testRadios { + r := testRadios[i] + err := repo.Put(&r) + if err != nil { + panic(err) + } + } + }) + + Describe("Count", func() { + It("returns the number of radios in the DB", func() { + Expect(repo.CountAll()).To(Equal(int64(2))) + }) + }) + + Describe("Delete", func() { + It("deletes existing item", func() { + err := repo.Delete(radioWithHomePage.ID) + + Expect(err).To(BeNil()) + + _, err = repo.Get(radioWithHomePage.ID) + Expect(err).To(MatchError(model.ErrNotFound)) + }) + }) + + Describe("Get", func() { + It("returns an existing item", func() { + res, err := repo.Get(radioWithHomePage.ID) + + Expect(err).To(BeNil()) + Expect(res.ID).To(Equal(radioWithHomePage.ID)) + }) + + It("errors when missing", func() { + _, err := repo.Get("notanid") + + Expect(err).To(MatchError(model.ErrNotFound)) + }) + }) + + Describe("GetAll", func() { + It("returns all items from the DB", func() { + all, err := repo.GetAll() + Expect(err).To(BeNil()) + Expect(all[0].ID).To(Equal(radioWithoutHomePage.ID)) + Expect(all[1].ID).To(Equal(radioWithHomePage.ID)) + }) + }) + + Describe("Put", func() { + It("successfully updates item", func() { + err := repo.Put(&model.Radio{ + ID: radioWithHomePage.ID, + Name: "New Name", + StreamUrl: "https://example.com:4533/app", + }) + + Expect(err).To(BeNil()) + + item, err := repo.Get(radioWithHomePage.ID) + Expect(err).To(BeNil()) + + Expect(item.HomePageUrl).To(Equal("")) + }) + + It("successfully creates item", func() { + err := repo.Put(&model.Radio{ + Name: "New radio", + StreamUrl: "https://example.com:4533/app", + }) + + Expect(err).To(BeNil()) + Expect(repo.CountAll()).To(Equal(int64(3))) + + all, err := repo.GetAll() + Expect(err).To(BeNil()) + Expect(all[2].StreamUrl).To(Equal("https://example.com:4533/app")) + }) + }) + }) + + Describe("Regular User", func() { + BeforeEach(func() { + ctx := log.NewContext(context.TODO()) + ctx = request.WithUser(ctx, model.User{ID: "userid", UserName: "userid", IsAdmin: false}) + repo = NewRadioRepository(ctx, orm.NewOrm()) + }) + + Describe("Count", func() { + It("returns the number of radios in the DB", func() { + Expect(repo.CountAll()).To(Equal(int64(2))) + }) + }) + + Describe("Delete", func() { + It("fails to delete items", func() { + err := repo.Delete(radioWithHomePage.ID) + + Expect(err).To(Equal(rest.ErrPermissionDenied)) + }) + }) + + Describe("Get", func() { + It("returns an existing item", func() { + res, err := repo.Get(radioWithHomePage.ID) + + Expect(err).To((BeNil())) + Expect(res.ID).To(Equal(radioWithHomePage.ID)) + }) + + It("errors when missing", func() { + _, err := repo.Get("notanid") + + Expect(err).To(MatchError(model.ErrNotFound)) + }) + }) + + Describe("GetAll", func() { + It("returns all items from the DB", func() { + all, err := repo.GetAll() + Expect(err).To(BeNil()) + Expect(all[0].ID).To(Equal(radioWithoutHomePage.ID)) + Expect(all[1].ID).To(Equal(radioWithHomePage.ID)) + }) + }) + + Describe("Put", func() { + It("fails to update item", func() { + err := repo.Put(&model.Radio{ + ID: radioWithHomePage.ID, + Name: "New Name", + StreamUrl: "https://example.com:4533/app", + }) + + Expect(err).To(Equal(rest.ErrPermissionDenied)) + }) + }) + }) +}) diff --git a/server/nativeapi/native_api.go b/server/nativeapi/native_api.go index fb53c4e4..499f617b 100644 --- a/server/nativeapi/native_api.go +++ b/server/nativeapi/native_api.go @@ -44,6 +44,7 @@ func (n *Router) routes() http.Handler { n.R(r, "/player", model.Player{}, true) n.R(r, "/playlist", model.Playlist{}, true) n.R(r, "/transcoding", model.Transcoding{}, conf.Server.EnableTranscodingConfig) + n.R(r, "/radio", model.Radio{}, true) n.RX(r, "/share", n.share.NewRepository, true) n.addPlaylistTrackRoute(r) diff --git a/server/subsonic/api.go b/server/subsonic/api.go index b2c59316..d16dad4a 100644 --- a/server/subsonic/api.go +++ b/server/subsonic/api.go @@ -153,6 +153,12 @@ func (api *Router) routes() http.Handler { hr(r, "stream", api.Stream) hr(r, "download", api.Download) }) + r.Group(func(r chi.Router) { + h(r, "createInternetRadioStation", api.CreateInternetRadio) + h(r, "deleteInternetRadioStation", api.DeleteInternetRadio) + h(r, "getInternetRadioStations", api.GetInternetRadios) + h(r, "updateInternetRadioStation", api.UpdateInternetRadio) + }) // Not Implemented (yet?) h501(r, "jukeboxControl") @@ -160,8 +166,6 @@ func (api *Router) routes() http.Handler { h501(r, "getShares", "createShare", "updateShare", "deleteShare") h501(r, "getPodcasts", "getNewestPodcasts", "refreshPodcasts", "createPodcastChannel", "deletePodcastChannel", "deletePodcastEpisode", "downloadPodcastEpisode") - h501(r, "getInternetRadioStations", "createInternetRadioStation", "updateInternetRadioStation", - "deleteInternetRadioStation") h501(r, "createUser", "updateUser", "deleteUser", "changePassword") // Deprecated/Won't implement/Out of scope endpoints diff --git a/server/subsonic/radio.go b/server/subsonic/radio.go new file mode 100644 index 00000000..c2953212 --- /dev/null +++ b/server/subsonic/radio.go @@ -0,0 +1,108 @@ +package subsonic + +import ( + "net/http" + + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/server/subsonic/responses" + "github.com/navidrome/navidrome/utils" +) + +func (api *Router) CreateInternetRadio(r *http.Request) (*responses.Subsonic, error) { + streamUrl, err := requiredParamString(r, "streamUrl") + if err != nil { + return nil, err + } + + name, err := requiredParamString(r, "name") + if err != nil { + return nil, err + } + + homepageUrl := utils.ParamString(r, "homepageUrl") + ctx := r.Context() + + radio := &model.Radio{ + StreamUrl: streamUrl, + HomePageUrl: homepageUrl, + Name: name, + } + + err = api.ds.Radio(ctx).Put(radio) + if err != nil { + return nil, err + } + return newResponse(), nil +} + +func (api *Router) DeleteInternetRadio(r *http.Request) (*responses.Subsonic, error) { + id, err := requiredParamString(r, "id") + + if err != nil { + return nil, err + } + + err = api.ds.Radio(r.Context()).Delete(id) + if err != nil { + return nil, err + } + return newResponse(), nil +} + +func (api *Router) GetInternetRadios(r *http.Request) (*responses.Subsonic, error) { + ctx := r.Context() + radios, err := api.ds.Radio(ctx).GetAll() + if err != nil { + return nil, err + } + + res := make([]responses.Radio, len(radios)) + for i, g := range radios { + res[i] = responses.Radio{ + ID: g.ID, + Name: g.Name, + StreamUrl: g.StreamUrl, + HomepageUrl: g.HomePageUrl, + } + } + + response := newResponse() + response.InternetRadioStations = &responses.InternetRadioStations{ + Radios: res, + } + + return response, nil +} + +func (api *Router) UpdateInternetRadio(r *http.Request) (*responses.Subsonic, error) { + id, err := requiredParamString(r, "id") + if err != nil { + return nil, err + } + + streamUrl, err := requiredParamString(r, "streamUrl") + if err != nil { + return nil, err + } + + name, err := requiredParamString(r, "name") + if err != nil { + return nil, err + } + + homepageUrl := utils.ParamString(r, "homepageUrl") + ctx := r.Context() + + radio := &model.Radio{ + ID: id, + StreamUrl: streamUrl, + HomePageUrl: homepageUrl, + Name: name, + } + + err = api.ds.Radio(ctx).Put(radio) + if err != nil { + return nil, err + } + return newResponse(), nil +} diff --git a/server/subsonic/responses/.snapshots/Responses InternetRadioStations with data should match .JSON b/server/subsonic/responses/.snapshots/Responses InternetRadioStations with data should match .JSON new file mode 100644 index 00000000..be214777 --- /dev/null +++ b/server/subsonic/responses/.snapshots/Responses InternetRadioStations with data should match .JSON @@ -0,0 +1 @@ +{"status":"ok","version":"1.8.0","type":"navidrome","serverVersion":"v0.0.0","internetRadioStations":{"internetRadioStation":[{"id":"12345678","streamUrl":"https://example.com/stream","name":"Example Stream","homePageUrl":"https://example.com"}]}} diff --git a/server/subsonic/responses/.snapshots/Responses InternetRadioStations with data should match .XML b/server/subsonic/responses/.snapshots/Responses InternetRadioStations with data should match .XML new file mode 100644 index 00000000..e86f6322 --- /dev/null +++ b/server/subsonic/responses/.snapshots/Responses InternetRadioStations with data should match .XML @@ -0,0 +1 @@ +12345678https://example.com/streamExample Streamhttps://example.com diff --git a/server/subsonic/responses/.snapshots/Responses InternetRadioStations without data should match .JSON b/server/subsonic/responses/.snapshots/Responses InternetRadioStations without data should match .JSON new file mode 100644 index 00000000..d08b5908 --- /dev/null +++ b/server/subsonic/responses/.snapshots/Responses InternetRadioStations without data should match .JSON @@ -0,0 +1 @@ +{"status":"ok","version":"1.8.0","type":"navidrome","serverVersion":"v0.0.0","internetRadioStations":{}} diff --git a/server/subsonic/responses/.snapshots/Responses InternetRadioStations without data should match .XML b/server/subsonic/responses/.snapshots/Responses InternetRadioStations without data should match .XML new file mode 100644 index 00000000..e2ed13e4 --- /dev/null +++ b/server/subsonic/responses/.snapshots/Responses InternetRadioStations without data should match .XML @@ -0,0 +1 @@ + diff --git a/server/subsonic/responses/responses.go b/server/subsonic/responses/responses.go index 79875123..0e1c4d57 100644 --- a/server/subsonic/responses/responses.go +++ b/server/subsonic/responses/responses.go @@ -47,6 +47,8 @@ type Subsonic struct { Bookmarks *Bookmarks `xml:"bookmarks,omitempty" json:"bookmarks,omitempty"` ScanStatus *ScanStatus `xml:"scanStatus,omitempty" json:"scanStatus,omitempty"` Lyrics *Lyrics `xml:"lyrics,omitempty" json:"lyrics,omitempty"` + + InternetRadioStations *InternetRadioStations `xml:"internetRadioStations,omitempty" json:"internetRadioStations,omitempty"` } type JsonWrapper struct { @@ -359,3 +361,14 @@ type Lyrics struct { Title string `xml:"title,omitempty,attr" json:"title,omitempty"` Value string `xml:",chardata" json:"value"` } + +type InternetRadioStations struct { + Radios []Radio `xml:"internetRadioStation" json:"internetRadioStation,omitempty"` +} + +type Radio struct { + ID string `xml:"id" json:"id"` + StreamUrl string `xml:"streamUrl" json:"streamUrl"` + Name string `xml:"name" json:"name"` + HomepageUrl string `xml:"homePageUrl" json:"homePageUrl"` +} diff --git a/server/subsonic/responses/responses_test.go b/server/subsonic/responses/responses_test.go index 6c275249..c133e5d7 100644 --- a/server/subsonic/responses/responses_test.go +++ b/server/subsonic/responses/responses_test.go @@ -594,4 +594,39 @@ var _ = Describe("Responses", func() { }) }) + + Describe("InternetRadioStations", func() { + BeforeEach(func() { + response.InternetRadioStations = &InternetRadioStations{} + }) + + Describe("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()) + }) + }) + + Describe("with data", func() { + BeforeEach(func() { + radio := make([]Radio, 1) + radio[0] = Radio{ + ID: "12345678", + StreamUrl: "https://example.com/stream", + Name: "Example Stream", + HomepageUrl: "https://example.com", + } + response.InternetRadioStations.Radios = radio + }) + + 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/tests/mock_persistence.go b/tests/mock_persistence.go index b68f559a..8df0547b 100644 --- a/tests/mock_persistence.go +++ b/tests/mock_persistence.go @@ -19,6 +19,7 @@ type MockDataStore struct { MockedTranscoding model.TranscodingRepository MockedUserProps model.UserPropsRepository MockedScrobbleBuffer model.ScrobbleBufferRepository + MockedRadioBuffer model.RadioRepository } func (db *MockDataStore) Album(context.Context) model.AlbumRepository { @@ -113,6 +114,13 @@ func (db *MockDataStore) ScrobbleBuffer(ctx context.Context) model.ScrobbleBuffe return db.MockedScrobbleBuffer } +func (db *MockDataStore) Radio(ctx context.Context) model.RadioRepository { + if db.MockedRadioBuffer == nil { + db.MockedRadioBuffer = CreateMockedRadioRepo() + } + return db.MockedRadioBuffer +} + func (db *MockDataStore) WithTx(block func(db model.DataStore) error) error { return block(db) } diff --git a/tests/mock_radio_repository.go b/tests/mock_radio_repository.go new file mode 100644 index 00000000..ec5af68f --- /dev/null +++ b/tests/mock_radio_repository.go @@ -0,0 +1,85 @@ +package tests + +import ( + "errors" + + "github.com/google/uuid" + "github.com/navidrome/navidrome/model" +) + +type MockedRadioRepo struct { + model.RadioRepository + data map[string]*model.Radio + all model.Radios + err bool + Options model.QueryOptions +} + +func CreateMockedRadioRepo() *MockedRadioRepo { + return &MockedRadioRepo{} +} + +func (m *MockedRadioRepo) SetError(err bool) { + m.err = err +} + +func (m *MockedRadioRepo) CountAll(options ...model.QueryOptions) (int64, error) { + if m.err { + return 0, errors.New("error") + } + return int64(len(m.data)), nil +} + +func (m *MockedRadioRepo) Delete(id string) error { + if m.err { + return errors.New("Error!") + } + + _, found := m.data[id] + + if !found { + return errors.New("not found") + } + + delete(m.data, id) + return nil +} + +func (m *MockedRadioRepo) Exists(id string) (bool, error) { + if m.err { + return false, errors.New("Error!") + } + _, found := m.data[id] + return found, nil +} + +func (m *MockedRadioRepo) Get(id string) (*model.Radio, error) { + if m.err { + return nil, errors.New("Error!") + } + if d, ok := m.data[id]; ok { + return d, nil + } + return nil, model.ErrNotFound +} + +func (m *MockedRadioRepo) GetAll(qo ...model.QueryOptions) (model.Radios, error) { + if len(qo) > 0 { + m.Options = qo[0] + } + if m.err { + return nil, errors.New("Error!") + } + return m.all, nil +} + +func (m *MockedRadioRepo) Put(radio *model.Radio) error { + if m.err { + return errors.New("error") + } + if radio.ID == "" { + radio.ID = uuid.NewString() + } + m.data[radio.ID] = radio + return nil +} diff --git a/ui/public/internet-radio-icon.svg b/ui/public/internet-radio-icon.svg new file mode 100644 index 00000000..d658eab9 --- /dev/null +++ b/ui/public/internet-radio-icon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/ui/src/App.js b/ui/src/App.js index 9281964f..bcf5894b 100644 --- a/ui/src/App.js +++ b/ui/src/App.js @@ -14,6 +14,7 @@ import song from './song' import album from './album' import artist from './artist' import playlist from './playlist' +import radio from './radio' import { Player } from './audioplayer' import customRoutes from './routes' import { @@ -99,6 +100,10 @@ const Admin = (props) => { , , , + , { const qi = { suffix: song.suffix, bitRate: song.bitRate } return ( - + {song.title} diff --git a/ui/src/audioplayer/Player.js b/ui/src/audioplayer/Player.js index 4df031ae..7f431451 100644 --- a/ui/src/audioplayer/Player.js +++ b/ui/src/audioplayer/Player.js @@ -41,7 +41,9 @@ const Player = () => { ) const { authenticated } = useAuthState() const visible = authenticated && playerState.queue.length > 0 + const isRadio = playerState.current?.isRadio || false const classes = useStyle({ + isRadio, visible, enableCoverAnimation: config.enableCoverAnimation, }) @@ -88,8 +90,11 @@ const Player = () => { playIndex: playerState.playIndex, autoPlay: playerState.clear || playerState.playIndex === 0, clearPriorAudioLists: playerState.clear, - extendsContent: , + extendsContent: ( + + ), defaultVolume: isMobilePlayer ? 1 : playerState.volume, + showMediaSession: !current.isRadio, } }, [playerState, defaultOptions, isMobilePlayer]) @@ -116,6 +121,10 @@ const Player = () => { return } + if (info.isRadio) { + return + } + if (!preloaded) { const next = nextSong() if (next != null) { @@ -149,7 +158,9 @@ const Player = () => { if (info.duration) { const song = info.song document.title = `${song.title} - ${song.artist} - Navidrome` - subsonic.nowPlaying(info.trackId) + if (!info.isRadio) { + subsonic.nowPlaying(info.trackId) + } setPreload(false) if (config.gaTrackingId) { ReactGA.event({ diff --git a/ui/src/audioplayer/PlayerToolbar.js b/ui/src/audioplayer/PlayerToolbar.js index 9e79d056..c44a9309 100644 --- a/ui/src/audioplayer/PlayerToolbar.js +++ b/ui/src/audioplayer/PlayerToolbar.js @@ -29,6 +29,7 @@ const Toolbar = ({ id }) => { ) } -const PlayerToolbar = ({ id }) => (id ? : ) +const PlayerToolbar = ({ id, isRadio }) => + id && !isRadio ? : export default PlayerToolbar diff --git a/ui/src/audioplayer/styles.js b/ui/src/audioplayer/styles.js index 955bcf1e..0573ac44 100644 --- a/ui/src/audioplayer/styles.js +++ b/ui/src/audioplayer/styles.js @@ -78,6 +78,17 @@ const useStyle = makeStyles( { display: 'none', }, + '& .music-player-panel .panel-content .progress-bar-content section.audio-main': + { + display: (props) => { + return props.isRadio ? 'none' : 'inline-flex' + }, + }, + '& .react-jinke-music-player-mobile-progress': { + display: (props) => { + return props.isRadio ? 'none' : 'flex' + }, + }, }, }), { name: 'NDAudioPlayer' } diff --git a/ui/src/i18n/en.json b/ui/src/i18n/en.json index 0eb57418..dd4a4942 100644 --- a/ui/src/i18n/en.json +++ b/ui/src/i18n/en.json @@ -160,6 +160,24 @@ "duplicate_song": "Add duplicated songs", "song_exist": "There are duplicates being added to the playlist. Would you like to add the duplicates or skip them?" } + }, + "radio": { + "name": "Radio |||| Radios", + "fields": { + "name": "Name", + "streamUrl": "Stream URL", + "homePageUrl": "Home Page URL", + "updatedAt": "Updated at", + "createdAt": "Created at" + }, + "notifications": { + "created": "Radio created", + "updated": "Radio updated", + "deleted": "Radio deleted" + }, + "actions": { + "playNow": "Play Now" + } } }, "ra": { @@ -188,7 +206,8 @@ "email": "Must be a valid email", "oneOf": "Must be one of: %{options}", "regex": "Must match a specific format (regexp): %{pattern}", - "unique": "Must be unique" + "unique": "Must be unique", + "url": "Must be a valid URL" }, "action": { "add_filter": "Add filter", @@ -310,6 +329,8 @@ "noPlaylistsAvailable": "None available", "delete_user_title": "Delete user '%{name}'", "delete_user_content": "Are you sure you want to delete this user and all their data (including playlists and preferences)?", + "delete_radio_title": "Delete radio '%{name}'", + "delete_radio_content": "Are you sure you want to remove this radio?", "notifications_blocked": "You have blocked Notifications for this site in your browser's settings", "notifications_not_available": "This browser does not support desktop notifications or you are not accessing Navidrome over https", "lastfmLinkSuccess": "Last.fm successfully linked and scrobbling enabled", @@ -402,4 +423,4 @@ "toggle_love": "Add this track to favourites" } } -} \ No newline at end of file +} diff --git a/ui/src/radio/DeleteRadioButton.js b/ui/src/radio/DeleteRadioButton.js new file mode 100644 index 00000000..75257a58 --- /dev/null +++ b/ui/src/radio/DeleteRadioButton.js @@ -0,0 +1,76 @@ +import { fade, makeStyles } from '@material-ui/core' +import DeleteIcon from '@material-ui/icons/Delete' +import clsx from 'clsx' +import React from 'react' +import { + Button, + Confirm, + useDeleteWithConfirmController, + useNotify, + useRedirect, +} from 'react-admin' + +const useStyles = makeStyles( + (theme) => ({ + deleteButton: { + color: theme.palette.error.main, + '&:hover': { + backgroundColor: fade(theme.palette.error.main, 0.12), + // Reset on mouse devices + '@media (hover: none)': { + backgroundColor: 'transparent', + }, + }, + }, + }), + { name: 'RaDeleteWithConfirmButton' } +) + +const DeleteRadioButton = (props) => { + const { resource, record, basePath, className, onClick, ...rest } = props + + const notify = useNotify() + const redirect = useRedirect() + + const onSuccess = () => { + notify('resources.radio.notifications.deleted') + redirect('/radio') + } + + const { open, loading, handleDialogOpen, handleDialogClose, handleDelete } = + useDeleteWithConfirmController({ + resource, + record, + basePath, + onClick, + onSuccess, + }) + + const classes = useStyles(props) + return ( + <> + + + + ) +} + +export default DeleteRadioButton diff --git a/ui/src/radio/RadioCreate.js b/ui/src/radio/RadioCreate.js new file mode 100644 index 00000000..0b7b7a55 --- /dev/null +++ b/ui/src/radio/RadioCreate.js @@ -0,0 +1,60 @@ +import React, { useCallback } from 'react' +import { + Create, + required, + SimpleForm, + TextInput, + useMutation, + useNotify, + useRedirect, + useTranslate, +} from 'react-admin' +import { Title } from '../common' + +const RadioCreate = (props) => { + const translate = useTranslate() + const [mutate] = useMutation() + const notify = useNotify() + const redirect = useRedirect() + + const resourceName = translate('resources.radio.name', { smart_count: 1 }) + const title = translate('ra.page.create', { + name: `${resourceName}`, + }) + + const save = useCallback( + async (values) => { + try { + await mutate( + { + type: 'create', + resource: 'radio', + payload: { data: values }, + }, + { returnPromise: true } + ) + notify('resources.radio.notifications.created', 'info', { + smart_count: 1, + }) + redirect('/radio') + } catch (error) { + if (error.body.errors) { + return error.body.errors + } + } + }, + [mutate, notify, redirect] + ) + + return ( + } {...props}> + + + + + + + ) +} + +export default RadioCreate diff --git a/ui/src/radio/RadioEdit.js b/ui/src/radio/RadioEdit.js new file mode 100644 index 00000000..6e9e8714 --- /dev/null +++ b/ui/src/radio/RadioEdit.js @@ -0,0 +1,134 @@ +import { Card, makeStyles } from '@material-ui/core' +import React, { useCallback } from 'react' +import { + DateField, + EditContextProvider, + required, + SaveButton, + SimpleForm, + TextInput, + Toolbar, + useEditController, + useMutation, + useNotify, + useRedirect, +} from 'react-admin' +import DeleteRadioButton from './DeleteRadioButton' + +const useStyles = makeStyles({ + toolbar: { + display: 'flex', + justifyContent: 'space-between', + }, +}) + +function urlValidate(value) { + if (!value) { + return undefined + } + + try { + new URL(value) + return undefined + } catch (_) { + return 'ra.validation.url' + } +} + +const RadioToolbar = (props) => ( + + + + +) + +const RadioEditLayout = ({ + hasCreate, + hasShow, + hasEdit, + hasList, + ...props +}) => { + const [mutate] = useMutation() + const notify = useNotify() + const redirect = useRedirect() + + const { record } = props + + const save = useCallback( + async (values) => { + try { + await mutate( + { + type: 'update', + resource: 'radio', + payload: { + id: values.id, + data: { + name: values.name, + streamUrl: values.streamUrl, + homePageUrl: values.homePageUrl, + }, + }, + }, + { returnPromise: true } + ) + notify('resources.radio.notifications.updated', 'info', { + smart_count: 1, + }) + redirect('/radio') + } catch (error) { + if (error.body.errors) { + return error.body.errors + } + } + }, + [mutate, notify, redirect] + ) + + if (!record) { + return null + } + + return ( + <> + {record && ( + + } + {...props} + > + + + + + + + + )} + + ) +} + +const RadioEdit = (props) => { + const controllerProps = useEditController(props) + return ( + + + + ) +} + +export default RadioEdit diff --git a/ui/src/radio/RadioList.js b/ui/src/radio/RadioList.js new file mode 100644 index 00000000..d5bf46f6 --- /dev/null +++ b/ui/src/radio/RadioList.js @@ -0,0 +1,139 @@ +import { makeStyles, useMediaQuery } from '@material-ui/core' +import React, { cloneElement } from 'react' +import { + CreateButton, + Datagrid, + DateField, + Filter, + List, + sanitizeListRestProps, + SearchInput, + SimpleList, + TextField, + TopToolbar, + UrlField, + useTranslate, +} from 'react-admin' +import { ToggleFieldsMenu, useSelectedFields } from '../common' +import { StreamField } from './StreamField' + +const useStyles = makeStyles({ + row: { + '&:hover': { + '& $contextMenu': { + visibility: 'visible', + }, + }, + }, + contextMenu: { + visibility: 'hidden', + }, +}) + +const RadioFilter = (props) => ( + + + +) + +const RadioListActions = ({ + className, + filters, + resource, + showFilter, + displayedFilters, + filterValues, + isAdmin, + ...rest +}) => { + const isNotSmall = useMediaQuery((theme) => theme.breakpoints.up('sm')) + const translate = useTranslate() + + return ( + + {isAdmin && ( + + {translate('ra.action.create')} + + )} + {filters && + cloneElement(filters, { + resource, + showFilter, + displayedFilters, + filterValues, + context: 'button', + })} + {isNotSmall && } + + ) +} + +const RadioList = ({ permissions, ...props }) => { + const isXsmall = useMediaQuery((theme) => theme.breakpoints.down('xs')) + + const classes = useStyles() + + const isAdmin = permissions === 'admin' + + const toggleableFields = { + name: , + homePageUrl: ( + e.stopPropagation()} + target="_blank" + rel="noopener noreferrer" + /> + ), + streamUrl: , + createdAt: , + updatedAt: , + } + + const columns = useSelectedFields({ + resource: 'radio', + columns: toggleableFields, + defaultOff: ['updatedAt'], + }) + + return ( + } + filters={} + perPage={isXsmall ? 25 : 10} + > + {isXsmall ? ( + ( + { + e.preventDefault() + e.stopPropagation() + }} + /> + )} + primaryText={(r) => r.name} + secondaryText={(r) => r.homePageUrl} + /> + ) : ( + + {columns} + + )} + + ) +} + +export default RadioList diff --git a/ui/src/radio/RadioShow.js b/ui/src/radio/RadioShow.js new file mode 100644 index 00000000..ae9500bc --- /dev/null +++ b/ui/src/radio/RadioShow.js @@ -0,0 +1,52 @@ +import { Card } from '@material-ui/core' +import React from 'react' +import { + DateField, + required, + ShowContextProvider, + SimpleShowLayout, + TextField, + UrlField, + useShowController, +} from 'react-admin' +import { StreamField } from './StreamField' + +const RadioShowLayout = ({ ...props }) => { + const { record } = props + + if (!record) { + return null + } + + return ( + <> + {record && ( + + + + + + + + + + )} + + ) +} + +const RadioShow = (props) => { + const controllerProps = useShowController(props) + return ( + + + + ) +} + +export default RadioShow diff --git a/ui/src/radio/StreamField.js b/ui/src/radio/StreamField.js new file mode 100644 index 00000000..57c8731c --- /dev/null +++ b/ui/src/radio/StreamField.js @@ -0,0 +1,50 @@ +import { Button, makeStyles } from '@material-ui/core' +import PropTypes from 'prop-types' +import React, { useCallback } from 'react' +import { useRecordContext } from 'react-admin' +import { useDispatch } from 'react-redux' +import { setTrack } from '../actions' +import { songFromRadio } from './helper' +import PlayArrowIcon from '@material-ui/icons/PlayArrow' + +const useStyles = makeStyles((theme) => ({ + button: { + padding: '5px 0px', + textTransform: 'none', + marginRight: theme.spacing(1.5), + }, +})) + +export const StreamField = ({ hideUrl, ...rest }) => { + const record = useRecordContext(rest) + const dispatch = useDispatch() + const classes = useStyles() + + const playTrack = useCallback( + async (evt) => { + evt.stopPropagation() + evt.preventDefault() + dispatch(setTrack(await songFromRadio(record))) + }, + [dispatch, record] + ) + + return ( + + ) +} + +StreamField.propTypes = { + label: PropTypes.string, + record: PropTypes.object, + source: PropTypes.string.isRequired, + hideUrl: PropTypes.bool, +} + +StreamField.defaultProps = { + addLabel: true, + hideUrl: false, +} diff --git a/ui/src/radio/helper.js b/ui/src/radio/helper.js new file mode 100644 index 00000000..5fc4098f --- /dev/null +++ b/ui/src/radio/helper.js @@ -0,0 +1,35 @@ +export async function songFromRadio(radio) { + if (!radio) { + return undefined + } + + let cover = 'internet-radio-icon.svg' + try { + const url = new URL(radio.homePageUrl ?? radio.streamUrl) + url.pathname = '/favicon.ico' + await resourceExists(url) + cover = url.toString() + } catch {} + + return { + ...radio, + title: radio.name, + album: radio.homePageUrl || radio.name, + artist: radio.name, + cover, + isRadio: true, + } +} + +const resourceExists = (url) => { + return new Promise((resolve, reject) => { + const img = new Image() + img.onload = function () { + resolve(url) + } + img.onerror = function () { + reject('not found') + } + img.src = url + }) +} diff --git a/ui/src/radio/index.js b/ui/src/radio/index.js new file mode 100644 index 00000000..1e49b7cc --- /dev/null +++ b/ui/src/radio/index.js @@ -0,0 +1,28 @@ +import RadioCreate from './RadioCreate' +import RadioEdit from './RadioEdit' +import RadioList from './RadioList' +import RadioShow from './RadioShow' +import DynamicMenuIcon from '../layout/DynamicMenuIcon' +import RadioIcon from '@material-ui/icons/Radio' +import RadioOutlinedIcon from '@material-ui/icons/RadioOutlined' +import React from 'react' + +const all = { + list: RadioList, + icon: ( + + ), + show: RadioShow, +} + +const admin = { + ...all, + create: RadioCreate, + edit: RadioEdit, +} + +export default { all, admin } diff --git a/ui/src/reducers/playerReducer.js b/ui/src/reducers/playerReducer.js index dc642e52..be9f42a8 100644 --- a/ui/src/reducers/playerReducer.js +++ b/ui/src/reducers/playerReducer.js @@ -23,6 +23,19 @@ const initialState = { const mapToAudioLists = (item) => { // If item comes from a playlist, trackId is mediaFileId const trackId = item.mediaFileId || item.id + + if (item.isRadio) { + return { + trackId, + uuid: uuidv4(), + name: item.name, + song: item, + musicSrc: item.streamUrl, + cover: item.cover, + isRadio: true, + } + } + const { lyrics } = item const timestampRegex = /(\[([0-9]{1,2}:)?([0-9]{1,2}:)([0-9]{1,2})(\.[0-9]{1,2})?\])/g