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:
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
+1
@@ -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"}]}}
|
||||||
+1
@@ -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>
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
{"status":"ok","version":"1.8.0","type":"navidrome","serverVersion":"v0.0.0","internetRadioStations":{}}
|
||||||
+1
@@ -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>
|
||||||
@@ -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())
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 |
@@ -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}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user