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
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
34 changed files with 1304 additions and 9 deletions

View File

@ -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
}

View File

@ -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

23
model/radio.go Normal file
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
}

View File

@ -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)
}

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 (
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",

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)

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))
})
})
})
})

View File

@ -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)

View File

@ -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

108
server/subsonic/radio.go Normal file
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
}

View File

@ -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"}]}}

View File

@ -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>

View File

@ -0,0 +1 @@
{"status":"ok","version":"1.8.0","type":"navidrome","serverVersion":"v0.0.0","internetRadioStations":{}}

View File

@ -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>

View File

@ -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"`
}

View File

@ -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())
})
})
})
})

View File

@ -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)
}

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

View File

@ -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) => {
<Resource name="album" {...album} options={{ subMenu: 'albumList' }} />,
<Resource name="artist" {...artist} />,
<Resource name="song" {...song} />,
<Resource
name="radio"
{...(permissions === 'admin' ? radio.admin : radio.all)}
/>,
<Resource
name="playlist"
{...playlist}

View File

@ -18,7 +18,14 @@ const AudioTitle = React.memo(({ audioInfo, isMobile }) => {
const qi = { suffix: song.suffix, bitRate: song.bitRate }
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 className={clsx(classes.songTitle, 'songTitle')}>
{song.title}

View File

@ -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: <PlayerToolbar id={current.trackId} />,
extendsContent: (
<PlayerToolbar id={current.trackId} isRadio={current.isRadio} />
),
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({

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

View File

@ -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' }

View File

@ -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"
}
}
}
}

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

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
ui/src/radio/RadioEdit.js Normal file
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
ui/src/radio/RadioList.js Normal file
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
ui/src/radio/RadioShow.js Normal file
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

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
ui/src/radio/helper.js Normal file
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
ui/src/radio/index.js Normal file
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 }

View File

@ -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