diff --git a/db/migration/20230115103212_create_internet_radio.go b/db/migration/20230115103212_create_internet_radio.go
new file mode 100644
index 000000000..b8575fc5b
--- /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 19a08c05a..844c70d6f 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 000000000..b07065777
--- /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 c9c99fc60..277cd9659 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 b2d5016d9..212671217 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 000000000..17e4d2499
--- /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 000000000..b87b1fc15
--- /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 fb53c4e4d..499f617b3 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 b2c59316c..d16dad4af 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 000000000..c2953212e
--- /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 000000000..be2147775
--- /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 000000000..e86f63223
--- /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 000000000..d08b59080
--- /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 000000000..e2ed13e4b
--- /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 798751233..0e1c4d570 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 6c2752490..c133e5d7a 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 b68f559ad..8df0547bb 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 000000000..ec5af68fc
--- /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 000000000..d658eab93
--- /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 9281964f9..bcf5894bc 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 4df031ae5..7f4314516 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 9e79d056f..c44a93097 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 955bcf1e1..0573ac440 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 0eb574188..dd4a4942e 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 000000000..75257a584
--- /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 000000000..0b7b7a555
--- /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 000000000..6e9e87145
--- /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 000000000..d5bf46f61
--- /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 000000000..ae9500bcf
--- /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 000000000..57c8731c8
--- /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 000000000..5fc4098f1
--- /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 000000000..1e49b7cc9
--- /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 dc642e527..be9f42a8c 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