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 <deluan@navidrome.org>
This commit is contained in:
Kendall Garner
2023-01-15 20:11:37 +00:00
committed by GitHub
parent aa21a2a305
commit 8877b1695a
34 changed files with 1304 additions and 9 deletions
@@ -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
}
+1
View File
@@ -29,6 +29,7 @@ type DataStore interface {
PlayQueue(ctx context.Context) PlayQueueRepository PlayQueue(ctx context.Context) PlayQueueRepository
Transcoding(ctx context.Context) TranscodingRepository Transcoding(ctx context.Context) TranscodingRepository
Player(ctx context.Context) PlayerRepository Player(ctx context.Context) PlayerRepository
Radio(ctx context.Context) RadioRepository
Share(ctx context.Context) ShareRepository Share(ctx context.Context) ShareRepository
Property(ctx context.Context) PropertyRepository Property(ctx context.Context) PropertyRepository
User(ctx context.Context) UserRepository User(ctx context.Context) UserRepository
+23
View File
@@ -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
}
+6
View File
@@ -52,6 +52,10 @@ func (s *SQLStore) Property(ctx context.Context) model.PropertyRepository {
return NewPropertyRepository(ctx, s.getOrmer()) 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 { func (s *SQLStore) UserProps(ctx context.Context) model.UserPropsRepository {
return NewUserPropsRepository(ctx, s.getOrmer()) 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) return s.Genre(ctx).(model.ResourceRepository)
case model.Playlist: case model.Playlist:
return s.Playlist(ctx).(model.ResourceRepository) return s.Playlist(ctx).(model.ResourceRepository)
case model.Radio:
return s.Radio(ctx).(model.ResourceRepository)
case model.Share: case model.Share:
return s.Share(ctx).(model.ResourceRepository) return s.Share(ctx).(model.ResourceRepository)
} }
+16 -1
View File
@@ -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 ( var (
plsBest model.Playlist plsBest model.Playlist
plsCool model.Playlist plsCool model.Playlist
@@ -84,7 +90,7 @@ func P(path string) string {
var _ = BeforeSuite(func() { var _ = BeforeSuite(func() {
o := orm.NewOrm() o := orm.NewOrm()
ctx := log.NewContext(context.TODO()) 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) ctx = request.WithUser(ctx, user)
ur := NewUserRepository(ctx, o) 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{ plsBest = model.Playlist{
Name: "Best", Name: "Best",
Comment: "No Comments", Comment: "No Comments",
+142
View File
@@ -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)
+176
View File
@@ -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))
})
})
})
})
+1
View File
@@ -44,6 +44,7 @@ func (n *Router) routes() http.Handler {
n.R(r, "/player", model.Player{}, true) n.R(r, "/player", model.Player{}, true)
n.R(r, "/playlist", model.Playlist{}, true) n.R(r, "/playlist", model.Playlist{}, true)
n.R(r, "/transcoding", model.Transcoding{}, conf.Server.EnableTranscodingConfig) 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.RX(r, "/share", n.share.NewRepository, true)
n.addPlaylistTrackRoute(r) n.addPlaylistTrackRoute(r)
+6 -2
View File
@@ -153,6 +153,12 @@ func (api *Router) routes() http.Handler {
hr(r, "stream", api.Stream) hr(r, "stream", api.Stream)
hr(r, "download", api.Download) 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?) // Not Implemented (yet?)
h501(r, "jukeboxControl") h501(r, "jukeboxControl")
@@ -160,8 +166,6 @@ func (api *Router) routes() http.Handler {
h501(r, "getShares", "createShare", "updateShare", "deleteShare") h501(r, "getShares", "createShare", "updateShare", "deleteShare")
h501(r, "getPodcasts", "getNewestPodcasts", "refreshPodcasts", "createPodcastChannel", "deletePodcastChannel", h501(r, "getPodcasts", "getNewestPodcasts", "refreshPodcasts", "createPodcastChannel", "deletePodcastChannel",
"deletePodcastEpisode", "downloadPodcastEpisode") "deletePodcastEpisode", "downloadPodcastEpisode")
h501(r, "getInternetRadioStations", "createInternetRadioStation", "updateInternetRadioStation",
"deleteInternetRadioStation")
h501(r, "createUser", "updateUser", "deleteUser", "changePassword") h501(r, "createUser", "updateUser", "deleteUser", "changePassword")
// Deprecated/Won't implement/Out of scope endpoints // Deprecated/Won't implement/Out of scope endpoints
+108
View File
@@ -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
}
@@ -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"}]}}
@@ -0,0 +1 @@
<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.8.0" type="navidrome" serverVersion="v0.0.0"><internetRadioStations><internetRadioStation><id>12345678</id><streamUrl>https://example.com/stream</streamUrl><name>Example Stream</name><homePageUrl>https://example.com</homePageUrl></internetRadioStation></internetRadioStations></subsonic-response>
@@ -0,0 +1 @@
{"status":"ok","version":"1.8.0","type":"navidrome","serverVersion":"v0.0.0","internetRadioStations":{}}
@@ -0,0 +1 @@
<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.8.0" type="navidrome" serverVersion="v0.0.0"><internetRadioStations></internetRadioStations></subsonic-response>
+13
View File
@@ -47,6 +47,8 @@ type Subsonic struct {
Bookmarks *Bookmarks `xml:"bookmarks,omitempty" json:"bookmarks,omitempty"` Bookmarks *Bookmarks `xml:"bookmarks,omitempty" json:"bookmarks,omitempty"`
ScanStatus *ScanStatus `xml:"scanStatus,omitempty" json:"scanStatus,omitempty"` ScanStatus *ScanStatus `xml:"scanStatus,omitempty" json:"scanStatus,omitempty"`
Lyrics *Lyrics `xml:"lyrics,omitempty" json:"lyrics,omitempty"` Lyrics *Lyrics `xml:"lyrics,omitempty" json:"lyrics,omitempty"`
InternetRadioStations *InternetRadioStations `xml:"internetRadioStations,omitempty" json:"internetRadioStations,omitempty"`
} }
type JsonWrapper struct { type JsonWrapper struct {
@@ -359,3 +361,14 @@ type Lyrics struct {
Title string `xml:"title,omitempty,attr" json:"title,omitempty"` Title string `xml:"title,omitempty,attr" json:"title,omitempty"`
Value string `xml:",chardata" json:"value"` 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"`
}
@@ -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())
})
})
})
}) })
+8
View File
@@ -19,6 +19,7 @@ type MockDataStore struct {
MockedTranscoding model.TranscodingRepository MockedTranscoding model.TranscodingRepository
MockedUserProps model.UserPropsRepository MockedUserProps model.UserPropsRepository
MockedScrobbleBuffer model.ScrobbleBufferRepository MockedScrobbleBuffer model.ScrobbleBufferRepository
MockedRadioBuffer model.RadioRepository
} }
func (db *MockDataStore) Album(context.Context) model.AlbumRepository { func (db *MockDataStore) Album(context.Context) model.AlbumRepository {
@@ -113,6 +114,13 @@ func (db *MockDataStore) ScrobbleBuffer(ctx context.Context) model.ScrobbleBuffe
return db.MockedScrobbleBuffer 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 { func (db *MockDataStore) WithTx(block func(db model.DataStore) error) error {
return block(db) return block(db)
} }
+85
View File
@@ -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
}
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.8 KiB

+5
View File
@@ -14,6 +14,7 @@ import song from './song'
import album from './album' import album from './album'
import artist from './artist' import artist from './artist'
import playlist from './playlist' import playlist from './playlist'
import radio from './radio'
import { Player } from './audioplayer' import { Player } from './audioplayer'
import customRoutes from './routes' import customRoutes from './routes'
import { import {
@@ -99,6 +100,10 @@ const Admin = (props) => {
<Resource name="album" {...album} options={{ subMenu: 'albumList' }} />, <Resource name="album" {...album} options={{ subMenu: 'albumList' }} />,
<Resource name="artist" {...artist} />, <Resource name="artist" {...artist} />,
<Resource name="song" {...song} />, <Resource name="song" {...song} />,
<Resource
name="radio"
{...(permissions === 'admin' ? radio.admin : radio.all)}
/>,
<Resource <Resource
name="playlist" name="playlist"
{...playlist} {...playlist}
+8 -1
View File
@@ -18,7 +18,14 @@ const AudioTitle = React.memo(({ audioInfo, isMobile }) => {
const qi = { suffix: song.suffix, bitRate: song.bitRate } const qi = { suffix: song.suffix, bitRate: song.bitRate }
return ( return (
<Link to={`/album/${song.albumId}/show`} className={className}> <Link
to={
audioInfo.isRadio
? `/radio/${audioInfo.trackId}/show`
: `/album/${song.albumId}/show`
}
className={className}
>
<span> <span>
<span className={clsx(classes.songTitle, 'songTitle')}> <span className={clsx(classes.songTitle, 'songTitle')}>
{song.title} {song.title}
+13 -2
View File
@@ -41,7 +41,9 @@ const Player = () => {
) )
const { authenticated } = useAuthState() const { authenticated } = useAuthState()
const visible = authenticated && playerState.queue.length > 0 const visible = authenticated && playerState.queue.length > 0
const isRadio = playerState.current?.isRadio || false
const classes = useStyle({ const classes = useStyle({
isRadio,
visible, visible,
enableCoverAnimation: config.enableCoverAnimation, enableCoverAnimation: config.enableCoverAnimation,
}) })
@@ -88,8 +90,11 @@ const Player = () => {
playIndex: playerState.playIndex, playIndex: playerState.playIndex,
autoPlay: playerState.clear || playerState.playIndex === 0, autoPlay: playerState.clear || playerState.playIndex === 0,
clearPriorAudioLists: playerState.clear, clearPriorAudioLists: playerState.clear,
extendsContent: <PlayerToolbar id={current.trackId} />, extendsContent: (
<PlayerToolbar id={current.trackId} isRadio={current.isRadio} />
),
defaultVolume: isMobilePlayer ? 1 : playerState.volume, defaultVolume: isMobilePlayer ? 1 : playerState.volume,
showMediaSession: !current.isRadio,
} }
}, [playerState, defaultOptions, isMobilePlayer]) }, [playerState, defaultOptions, isMobilePlayer])
@@ -116,6 +121,10 @@ const Player = () => {
return return
} }
if (info.isRadio) {
return
}
if (!preloaded) { if (!preloaded) {
const next = nextSong() const next = nextSong()
if (next != null) { if (next != null) {
@@ -149,7 +158,9 @@ const Player = () => {
if (info.duration) { if (info.duration) {
const song = info.song const song = info.song
document.title = `${song.title} - ${song.artist} - Navidrome` document.title = `${song.title} - ${song.artist} - Navidrome`
subsonic.nowPlaying(info.trackId) if (!info.isRadio) {
subsonic.nowPlaying(info.trackId)
}
setPreload(false) setPreload(false)
if (config.gaTrackingId) { if (config.gaTrackingId) {
ReactGA.event({ ReactGA.event({
+2 -1
View File
@@ -29,6 +29,7 @@ const Toolbar = ({ id }) => {
) )
} }
const PlayerToolbar = ({ id }) => (id ? <Toolbar id={id} /> : <Placeholder />) const PlayerToolbar = ({ id, isRadio }) =>
id && !isRadio ? <Toolbar id={id} /> : <Placeholder />
export default PlayerToolbar export default PlayerToolbar
+11
View File
@@ -78,6 +78,17 @@ const useStyle = makeStyles(
{ {
display: 'none', 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' } { name: 'NDAudioPlayer' }
+23 -2
View File
@@ -160,6 +160,24 @@
"duplicate_song": "Add duplicated songs", "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?" "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": { "ra": {
@@ -188,7 +206,8 @@
"email": "Must be a valid email", "email": "Must be a valid email",
"oneOf": "Must be one of: %{options}", "oneOf": "Must be one of: %{options}",
"regex": "Must match a specific format (regexp): %{pattern}", "regex": "Must match a specific format (regexp): %{pattern}",
"unique": "Must be unique" "unique": "Must be unique",
"url": "Must be a valid URL"
}, },
"action": { "action": {
"add_filter": "Add filter", "add_filter": "Add filter",
@@ -310,6 +329,8 @@
"noPlaylistsAvailable": "None available", "noPlaylistsAvailable": "None available",
"delete_user_title": "Delete user '%{name}'", "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_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_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", "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", "lastfmLinkSuccess": "Last.fm successfully linked and scrobbling enabled",
@@ -402,4 +423,4 @@
"toggle_love": "Add this track to favourites" "toggle_love": "Add this track to favourites"
} }
} }
} }
+76
View File
@@ -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 (
<>
<Button
onClick={handleDialogOpen}
label="ra.action.delete"
key="button"
className={clsx('ra-delete-button', classes.deleteButton, className)}
{...rest}
>
<DeleteIcon />
</Button>
<Confirm
isOpen={open}
loading={loading}
title="message.delete_radio_title"
content="message.delete_radio_content"
translateOptions={{
name: record.name,
}}
onConfirm={handleDelete}
onClose={handleDialogClose}
/>
</>
)
}
export default DeleteRadioButton
+60
View File
@@ -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 (
<Create title={<Title subTitle={title} />} {...props}>
<SimpleForm save={save} variant={'outlined'}>
<TextInput source="name" validate={[required()]} />
<TextInput type="url" source="streamUrl" validate={[required()]} />
<TextInput type="url" source="homepageUrl" />
</SimpleForm>
</Create>
)
}
export default RadioCreate
+134
View File
@@ -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) => (
<Toolbar {...props} classes={useStyles()}>
<SaveButton disabled={props.pristine} />
<DeleteRadioButton />
</Toolbar>
)
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 && (
<Card>
<SimpleForm
variant="outlined"
save={save}
toolbar={<RadioToolbar />}
{...props}
>
<TextInput source="name" validate={[required()]} />
<TextInput
type="url"
source="streamUrl"
fullWidth
validate={[required(), urlValidate]}
/>
<TextInput
type="url"
source="homePageUrl"
fullWidth
validate={[urlValidate]}
/>
<DateField variant="body1" source="updatedAt" showTime />
<DateField variant="body1" source="createdAt" showTime />
</SimpleForm>
</Card>
)}
</>
)
}
const RadioEdit = (props) => {
const controllerProps = useEditController(props)
return (
<EditContextProvider value={controllerProps}>
<RadioEditLayout {...props} record={controllerProps.record} />
</EditContextProvider>
)
}
export default RadioEdit
+139
View File
@@ -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) => (
<Filter {...props} variant={'outlined'}>
<SearchInput id="search" source="name" alwaysOn />
</Filter>
)
const RadioListActions = ({
className,
filters,
resource,
showFilter,
displayedFilters,
filterValues,
isAdmin,
...rest
}) => {
const isNotSmall = useMediaQuery((theme) => theme.breakpoints.up('sm'))
const translate = useTranslate()
return (
<TopToolbar className={className} {...sanitizeListRestProps(rest)}>
{isAdmin && (
<CreateButton basePath="/radio">
{translate('ra.action.create')}
</CreateButton>
)}
{filters &&
cloneElement(filters, {
resource,
showFilter,
displayedFilters,
filterValues,
context: 'button',
})}
{isNotSmall && <ToggleFieldsMenu resource="radio" />}
</TopToolbar>
)
}
const RadioList = ({ permissions, ...props }) => {
const isXsmall = useMediaQuery((theme) => theme.breakpoints.down('xs'))
const classes = useStyles()
const isAdmin = permissions === 'admin'
const toggleableFields = {
name: <TextField source="name" />,
homePageUrl: (
<UrlField
source="homePageUrl"
onClick={(e) => e.stopPropagation()}
target="_blank"
rel="noopener noreferrer"
/>
),
streamUrl: <StreamField source="streamUrl" />,
createdAt: <DateField source="createdAt" showTime />,
updatedAt: <DateField source="updatedAt" showTime />,
}
const columns = useSelectedFields({
resource: 'radio',
columns: toggleableFields,
defaultOff: ['updatedAt'],
})
return (
<List
{...props}
exporter={false}
bulkActionButtons={isAdmin ? undefined : false}
hasCreate={isAdmin}
actions={<RadioListActions isAdmin={isAdmin} />}
filters={<RadioFilter />}
perPage={isXsmall ? 25 : 10}
>
{isXsmall ? (
<SimpleList
linkType={isAdmin ? 'edit' : 'show'}
leftIcon={(r) => (
<StreamField
record={r}
source={'streamUrl'}
hideUrl
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
}}
/>
)}
primaryText={(r) => r.name}
secondaryText={(r) => r.homePageUrl}
/>
) : (
<Datagrid
rowClick={isAdmin ? 'edit' : 'show'}
classes={{ row: classes.row }}
>
{columns}
</Datagrid>
)}
</List>
)
}
export default RadioList
+52
View File
@@ -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 && (
<Card>
<SimpleShowLayout>
<TextField source="name" validate={[required()]} />
<StreamField source="streamUrl" />
<UrlField
type="url"
source="homePageUrl"
rel="noreferrer noopener"
target="_blank"
/>
<DateField variant="body1" source="updatedAt" showTime />
<DateField variant="body1" source="createdAt" showTime />
</SimpleShowLayout>
</Card>
)}
</>
)
}
const RadioShow = (props) => {
const controllerProps = useShowController(props)
return (
<ShowContextProvider value={controllerProps}>
<RadioShowLayout {...props} record={controllerProps.record} />
</ShowContextProvider>
)
}
export default RadioShow
+50
View File
@@ -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 (
<Button className={classes.button} onClick={playTrack}>
<PlayArrowIcon />
{!hideUrl && record.streamUrl}
</Button>
)
}
StreamField.propTypes = {
label: PropTypes.string,
record: PropTypes.object,
source: PropTypes.string.isRequired,
hideUrl: PropTypes.bool,
}
StreamField.defaultProps = {
addLabel: true,
hideUrl: false,
}
+35
View File
@@ -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
})
}
+28
View File
@@ -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: (
<DynamicMenuIcon
path={'radio'}
icon={RadioOutlinedIcon}
activeIcon={RadioIcon}
/>
),
show: RadioShow,
}
const admin = {
...all,
create: RadioCreate,
edit: RadioEdit,
}
export default { all, admin }
+13
View File
@@ -23,6 +23,19 @@ const initialState = {
const mapToAudioLists = (item) => { const mapToAudioLists = (item) => {
// If item comes from a playlist, trackId is mediaFileId // If item comes from a playlist, trackId is mediaFileId
const trackId = item.mediaFileId || item.id 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 { lyrics } = item
const timestampRegex = const timestampRegex =
/(\[([0-9]{1,2}:)?([0-9]{1,2}:)([0-9]{1,2})(\.[0-9]{1,2})?\])/g /(\[([0-9]{1,2}:)?([0-9]{1,2}:)([0-9]{1,2})(\.[0-9]{1,2})?\])/g