feat(ui): add cover art support for internet radio stations (#5229)
* feat(artwork): add KindRadioArtwork and EntityRadio constant * feat(model): add UploadedImage field and artwork methods to Radio * feat(model): add Radio to GetEntityByID lookup chain * feat(db): add uploaded_image column to radio table * feat(artwork): add radio artwork reader with uploaded image fallback * feat(api): add radio image upload/delete endpoints * feat(ui): add radio artwork ID prefix to getCoverArtUrl * feat(ui): add cover art display and upload to RadioEdit * feat(ui): add cover art thumbnails to radio list * feat(ui): prefer artwork URL in radio player helper * refactor: remove redundant code in radio artwork - Remove duplicate Avatar rendering in RadioList by reusing CoverArtField - Remove redundant UpdatedAt assignment in radio image handlers (already set by repository Put) * refactor(ui): extract shared useImageLoadingState hook Move image loading/error/lightbox state management into a shared useImageLoadingState hook in common/. Consolidates duplicated logic from AlbumDetails, PlaylistDetails, RadioEdit, and artist detail views. * feat(ui): use radio placeholder icon when no uploaded image Remove album placeholder fallback from radio artwork reader so radios without an uploaded image return ErrUnavailable. On the frontend, show the internet-radio-icon.svg placeholder instead of requesting server artwork when no image is uploaded, allowing favicon fallback in the player. * refactor(ui): update defaultOff fields in useSelectedFields for RadioList Signed-off-by: Deluan <deluan@navidrome.org> * fix: address code review feedback - Add missing alt attribute to CardMedia in RadioEdit for accessibility - Fix UpdateInternetRadio to preserve UploadedImage field by fetching existing radio before updating (prevents Subsonic API from clearing custom artwork) - Add Reader() level tests to verify ErrUnavailable is returned when radio has no uploaded image * refactor: add colsToUpdate to RadioRepository.Put Use the base sqlRepository.put with column filtering instead of hand-rolled SQL. UpdateInternetRadio now specifies only the Subsonic API fields, preventing UploadedImage from being cleared. Image upload/delete handlers specify only UploadedImage. * fix: ensure UpdatedAt is included in colsToUpdate for radio Put --------- Signed-off-by: Deluan <deluan@navidrome.org>
This commit is contained in:
@@ -108,6 +108,7 @@ const (
|
|||||||
const (
|
const (
|
||||||
EntityArtist = "artist"
|
EntityArtist = "artist"
|
||||||
EntityPlaylist = "playlist"
|
EntityPlaylist = "playlist"
|
||||||
|
EntityRadio = "radio"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
|
|||||||
@@ -124,6 +124,8 @@ func (a *artwork) getArtworkReader(ctx context.Context, artID model.ArtworkID, s
|
|||||||
artReader, err = newPlaylistArtworkReader(ctx, a, artID)
|
artReader, err = newPlaylistArtworkReader(ctx, a, artID)
|
||||||
case model.KindDiscArtwork:
|
case model.KindDiscArtwork:
|
||||||
artReader, err = newDiscArtworkReader(ctx, a, artID)
|
artReader, err = newDiscArtworkReader(ctx, a, artID)
|
||||||
|
case model.KindRadioArtwork:
|
||||||
|
artReader, err = newRadioArtworkReader(ctx, a, artID)
|
||||||
default:
|
default:
|
||||||
return nil, ErrUnavailable
|
return nil, ErrUnavailable
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
package artwork
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"io"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/navidrome/navidrome/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
type radioArtworkReader struct {
|
||||||
|
cacheKey
|
||||||
|
a *artwork
|
||||||
|
radio model.Radio
|
||||||
|
}
|
||||||
|
|
||||||
|
func newRadioArtworkReader(ctx context.Context, artwork *artwork, artID model.ArtworkID) (*radioArtworkReader, error) {
|
||||||
|
r, err := artwork.ds.Radio(ctx).Get(artID.ID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
a := &radioArtworkReader{a: artwork, radio: *r}
|
||||||
|
a.cacheKey.artID = artID
|
||||||
|
a.cacheKey.lastUpdate = r.UpdatedAt
|
||||||
|
return a, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *radioArtworkReader) LastUpdated() time.Time {
|
||||||
|
return a.lastUpdate
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *radioArtworkReader) Reader(ctx context.Context) (io.ReadCloser, string, error) {
|
||||||
|
return selectImageReader(ctx, a.artID,
|
||||||
|
a.fromRadioUploadedImage(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *radioArtworkReader) fromRadioUploadedImage() sourceFunc {
|
||||||
|
return fromLocalFile(a.radio.UploadedImagePath())
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
package artwork
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
|
"github.com/navidrome/navidrome/conf"
|
||||||
|
"github.com/navidrome/navidrome/conf/configtest"
|
||||||
|
"github.com/navidrome/navidrome/model"
|
||||||
|
. "github.com/onsi/ginkgo/v2"
|
||||||
|
. "github.com/onsi/gomega"
|
||||||
|
)
|
||||||
|
|
||||||
|
var _ = Describe("radioArtworkReader", func() {
|
||||||
|
var (
|
||||||
|
tempDir string
|
||||||
|
reader *radioArtworkReader
|
||||||
|
)
|
||||||
|
|
||||||
|
BeforeEach(func() {
|
||||||
|
DeferCleanup(configtest.SetupConfig())
|
||||||
|
tempDir = GinkgoT().TempDir()
|
||||||
|
conf.Server.DataFolder = tempDir
|
||||||
|
|
||||||
|
Expect(os.MkdirAll(filepath.Join(tempDir, "artwork", "radio"), 0755)).To(Succeed())
|
||||||
|
|
||||||
|
reader = &radioArtworkReader{}
|
||||||
|
})
|
||||||
|
|
||||||
|
Describe("fromRadioUploadedImage", func() {
|
||||||
|
When("radio has an uploaded image", func() {
|
||||||
|
It("returns the uploaded image", func() {
|
||||||
|
imgPath := filepath.Join(tempDir, "artwork", "radio", "rd-1_test.jpg")
|
||||||
|
Expect(os.WriteFile(imgPath, []byte("uploaded radio image"), 0600)).To(Succeed())
|
||||||
|
|
||||||
|
reader.radio = model.Radio{ID: "rd-1", UploadedImage: "rd-1_test.jpg"}
|
||||||
|
sf := reader.fromRadioUploadedImage()
|
||||||
|
r, path, err := sf()
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
Expect(r).ToNot(BeNil())
|
||||||
|
Expect(path).To(Equal(imgPath))
|
||||||
|
r.Close()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
When("radio has no uploaded image", func() {
|
||||||
|
It("returns nil reader (falls through)", func() {
|
||||||
|
reader.radio = model.Radio{ID: "rd-1"}
|
||||||
|
sf := reader.fromRadioUploadedImage()
|
||||||
|
r, path, err := sf()
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
Expect(r).To(BeNil())
|
||||||
|
Expect(path).To(BeEmpty())
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
Describe("Reader", func() {
|
||||||
|
When("radio has an uploaded image", func() {
|
||||||
|
It("returns the image reader", func() {
|
||||||
|
imgPath := filepath.Join(tempDir, "artwork", "radio", "rd-1_test.jpg")
|
||||||
|
Expect(os.WriteFile(imgPath, []byte("uploaded radio image"), 0600)).To(Succeed())
|
||||||
|
|
||||||
|
reader.radio = model.Radio{ID: "rd-1", UploadedImage: "rd-1_test.jpg"}
|
||||||
|
reader.cacheKey.artID = model.ArtworkID{Kind: model.KindRadioArtwork, ID: "rd-1"}
|
||||||
|
r, _, err := reader.Reader(context.Background())
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
Expect(r).ToNot(BeNil())
|
||||||
|
r.Close()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
When("radio has no uploaded image", func() {
|
||||||
|
It("returns ErrUnavailable", func() {
|
||||||
|
reader.radio = model.Radio{ID: "rd-1"}
|
||||||
|
reader.cacheKey.artID = model.ArtworkID{Kind: model.KindRadioArtwork, ID: "rd-1"}
|
||||||
|
r, _, err := reader.Reader(context.Background())
|
||||||
|
Expect(err).To(MatchError(ErrUnavailable))
|
||||||
|
Expect(r).To(BeNil())
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
package migrations
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
|
||||||
|
"github.com/pressly/goose/v3"
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
goose.AddMigrationContext(upAddRadioUploadedImage, downAddRadioUploadedImage)
|
||||||
|
}
|
||||||
|
|
||||||
|
func upAddRadioUploadedImage(ctx context.Context, tx *sql.Tx) error {
|
||||||
|
_, err := tx.ExecContext(ctx, `ALTER TABLE radio ADD COLUMN uploaded_image VARCHAR(255) NOT NULL DEFAULT ''`)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func downAddRadioUploadedImage(ctx context.Context, tx *sql.Tx) error {
|
||||||
|
// This code is executed when the migration is rolled back.
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -23,6 +23,7 @@ var (
|
|||||||
KindAlbumArtwork = Kind{"al", "album"}
|
KindAlbumArtwork = Kind{"al", "album"}
|
||||||
KindPlaylistArtwork = Kind{"pl", "playlist"}
|
KindPlaylistArtwork = Kind{"pl", "playlist"}
|
||||||
KindDiscArtwork = Kind{"dc", "disc"}
|
KindDiscArtwork = Kind{"dc", "disc"}
|
||||||
|
KindRadioArtwork = Kind{"ra", "radio"}
|
||||||
)
|
)
|
||||||
|
|
||||||
var artworkKindMap = map[string]Kind{
|
var artworkKindMap = map[string]Kind{
|
||||||
@@ -31,6 +32,7 @@ var artworkKindMap = map[string]Kind{
|
|||||||
KindAlbumArtwork.prefix: KindAlbumArtwork,
|
KindAlbumArtwork.prefix: KindAlbumArtwork,
|
||||||
KindPlaylistArtwork.prefix: KindPlaylistArtwork,
|
KindPlaylistArtwork.prefix: KindPlaylistArtwork,
|
||||||
KindDiscArtwork.prefix: KindDiscArtwork,
|
KindDiscArtwork.prefix: KindDiscArtwork,
|
||||||
|
KindRadioArtwork.prefix: KindRadioArtwork,
|
||||||
}
|
}
|
||||||
|
|
||||||
type ArtworkID struct {
|
type ArtworkID struct {
|
||||||
@@ -139,3 +141,11 @@ func artworkIDFromArtist(ar Artist) ArtworkID {
|
|||||||
ID: ar.ID,
|
ID: ar.ID,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func artworkIDFromRadio(r Radio) ArtworkID {
|
||||||
|
return ArtworkID{
|
||||||
|
Kind: KindRadioArtwork,
|
||||||
|
ID: r.ID,
|
||||||
|
LastUpdate: r.UpdatedAt,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -22,5 +22,9 @@ func GetEntityByID(ctx context.Context, ds DataStore, id string) (any, error) {
|
|||||||
if err == nil {
|
if err == nil {
|
||||||
return mf, nil
|
return mf, nil
|
||||||
}
|
}
|
||||||
|
r, err := ds.Radio(ctx).Get(id)
|
||||||
|
if err == nil {
|
||||||
|
return r, nil
|
||||||
|
}
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
+15
-2
@@ -1,16 +1,29 @@
|
|||||||
package model
|
package model
|
||||||
|
|
||||||
import "time"
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/navidrome/navidrome/consts"
|
||||||
|
)
|
||||||
|
|
||||||
type Radio struct {
|
type Radio struct {
|
||||||
ID string `structs:"id" json:"id"`
|
ID string `structs:"id" json:"id"`
|
||||||
StreamUrl string `structs:"stream_url" json:"streamUrl"`
|
StreamUrl string `structs:"stream_url" json:"streamUrl"`
|
||||||
Name string `structs:"name" json:"name"`
|
Name string `structs:"name" json:"name"`
|
||||||
HomePageUrl string `structs:"home_page_url" json:"homePageUrl"`
|
HomePageUrl string `structs:"home_page_url" json:"homePageUrl"`
|
||||||
|
UploadedImage string `structs:"uploaded_image" json:"uploadedImage,omitempty"`
|
||||||
CreatedAt time.Time `structs:"created_at" json:"createdAt"`
|
CreatedAt time.Time `structs:"created_at" json:"createdAt"`
|
||||||
UpdatedAt time.Time `structs:"updated_at" json:"updatedAt"`
|
UpdatedAt time.Time `structs:"updated_at" json:"updatedAt"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r Radio) CoverArtID() ArtworkID {
|
||||||
|
return artworkIDFromRadio(r)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r Radio) UploadedImagePath() string {
|
||||||
|
return UploadedImagePath(consts.EntityRadio, r.UploadedImage)
|
||||||
|
}
|
||||||
|
|
||||||
type Radios []Radio
|
type Radios []Radio
|
||||||
|
|
||||||
type RadioRepository interface {
|
type RadioRepository interface {
|
||||||
@@ -19,5 +32,5 @@ type RadioRepository interface {
|
|||||||
Delete(id string) error
|
Delete(id string) error
|
||||||
Get(id string) (*Radio, error)
|
Get(id string) (*Radio, error)
|
||||||
GetAll(options ...QueryOptions) (Radios, error)
|
GetAll(options ...QueryOptions) (Radios, error)
|
||||||
Put(u *Radio) error
|
Put(u *Radio, colsToUpdate ...string) error
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
package model_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"path/filepath"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/navidrome/navidrome/conf"
|
||||||
|
"github.com/navidrome/navidrome/conf/configtest"
|
||||||
|
"github.com/navidrome/navidrome/model"
|
||||||
|
. "github.com/onsi/ginkgo/v2"
|
||||||
|
. "github.com/onsi/gomega"
|
||||||
|
)
|
||||||
|
|
||||||
|
var _ = Describe("Radio", func() {
|
||||||
|
Describe("CoverArtID", func() {
|
||||||
|
It("returns a radio artwork ID", func() {
|
||||||
|
now := time.Now()
|
||||||
|
r := model.Radio{ID: "rd-1", UpdatedAt: now}
|
||||||
|
artID := r.CoverArtID()
|
||||||
|
Expect(artID.Kind).To(Equal(model.KindRadioArtwork))
|
||||||
|
Expect(artID.ID).To(Equal("rd-1"))
|
||||||
|
Expect(artID.LastUpdate).To(Equal(now))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
Describe("UploadedImagePath", func() {
|
||||||
|
BeforeEach(func() {
|
||||||
|
DeferCleanup(configtest.SetupConfig())
|
||||||
|
conf.Server.DataFolder = "/data"
|
||||||
|
})
|
||||||
|
|
||||||
|
It("returns empty string when no image uploaded", func() {
|
||||||
|
r := model.Radio{ID: "rd-1"}
|
||||||
|
Expect(r.UploadedImagePath()).To(BeEmpty())
|
||||||
|
})
|
||||||
|
|
||||||
|
It("returns full path when image is set", func() {
|
||||||
|
r := model.Radio{ID: "rd-1", UploadedImage: "rd-1_test.jpg"}
|
||||||
|
Expect(r.UploadedImagePath()).To(Equal(filepath.Join("/data", "artwork", "radio", "rd-1_test.jpg")))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -58,34 +58,20 @@ func (r *radioRepository) GetAll(options ...model.QueryOptions) (model.Radios, e
|
|||||||
return res, err
|
return res, err
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *radioRepository) Put(radio *model.Radio) error {
|
func (r *radioRepository) Put(radio *model.Radio, colsToUpdate ...string) error {
|
||||||
if !r.isPermitted() {
|
if !r.isPermitted() {
|
||||||
return rest.ErrPermissionDenied
|
return rest.ErrPermissionDenied
|
||||||
}
|
}
|
||||||
|
|
||||||
var values map[string]any
|
|
||||||
|
|
||||||
radio.UpdatedAt = time.Now()
|
radio.UpdatedAt = time.Now()
|
||||||
|
|
||||||
if radio.ID == "" {
|
if radio.ID == "" {
|
||||||
radio.CreatedAt = time.Now()
|
radio.CreatedAt = time.Now()
|
||||||
radio.ID = id.NewRandom()
|
radio.ID = id.NewRandom()
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
if len(colsToUpdate) > 0 {
|
||||||
|
colsToUpdate = append(colsToUpdate, "UpdatedAt")
|
||||||
}
|
}
|
||||||
|
_, err := r.put(radio.ID, radio, colsToUpdate...)
|
||||||
values["created_at"] = time.Now()
|
|
||||||
insert := Insert(r.tableName).SetMap(values)
|
|
||||||
_, err := r.executeSQL(insert)
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ func (api *Router) routes() http.Handler {
|
|||||||
api.R(r, "/genre", model.Genre{}, false)
|
api.R(r, "/genre", model.Genre{}, false)
|
||||||
api.R(r, "/player", model.Player{}, true)
|
api.R(r, "/player", model.Player{}, true)
|
||||||
api.R(r, "/transcoding", model.Transcoding{}, conf.Server.EnableTranscodingConfig)
|
api.R(r, "/transcoding", model.Transcoding{}, conf.Server.EnableTranscodingConfig)
|
||||||
api.R(r, "/radio", model.Radio{}, true)
|
api.addRadioRoute(r)
|
||||||
api.R(r, "/tag", model.Tag{}, true)
|
api.R(r, "/tag", model.Tag{}, true)
|
||||||
if conf.Server.EnableSharing {
|
if conf.Server.EnableSharing {
|
||||||
api.RX(r, "/share", api.share.NewRepository, true)
|
api.RX(r, "/share", api.share.NewRepository, true)
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
package nativeapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/deluan/rest"
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
"github.com/navidrome/navidrome/consts"
|
||||||
|
"github.com/navidrome/navidrome/model"
|
||||||
|
"github.com/navidrome/navidrome/server"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (api *Router) addRadioRoute(r chi.Router) {
|
||||||
|
constructor := func(ctx context.Context) rest.Repository {
|
||||||
|
return api.ds.Resource(ctx, model.Radio{})
|
||||||
|
}
|
||||||
|
r.Route("/radio", func(r chi.Router) {
|
||||||
|
r.Get("/", rest.GetAll(constructor))
|
||||||
|
r.Post("/", rest.Post(constructor))
|
||||||
|
r.Route("/{id}", func(r chi.Router) {
|
||||||
|
r.Use(server.URLParamsMiddleware)
|
||||||
|
r.Get("/", rest.Get(constructor))
|
||||||
|
r.Put("/", rest.Put(constructor))
|
||||||
|
r.Delete("/", rest.Delete(constructor))
|
||||||
|
r.Post("/image", api.uploadRadioImage())
|
||||||
|
r.Delete("/image", api.deleteRadioImage())
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (api *Router) uploadRadioImage() http.HandlerFunc {
|
||||||
|
return handleImageUpload(func(ctx context.Context, reader io.Reader, ext string) error {
|
||||||
|
radioID := chi.URLParamFromCtx(ctx, "id")
|
||||||
|
radio, err := api.ds.Radio(ctx).Get(radioID)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, model.ErrNotFound) {
|
||||||
|
return model.ErrNotFound
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
oldPath := radio.UploadedImagePath()
|
||||||
|
filename, err := api.imgUpload.SetImage(ctx, consts.EntityRadio, radio.ID, radio.Name, oldPath, reader, ext)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
radio.UploadedImage = filename
|
||||||
|
return api.ds.Radio(ctx).Put(radio, "UploadedImage")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (api *Router) deleteRadioImage() http.HandlerFunc {
|
||||||
|
return handleImageDelete(func(ctx context.Context) error {
|
||||||
|
radioID := chi.URLParamFromCtx(ctx, "id")
|
||||||
|
radio, err := api.ds.Radio(ctx).Get(radioID)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, model.ErrNotFound) {
|
||||||
|
return model.ErrNotFound
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := api.imgUpload.RemoveImage(ctx, radio.UploadedImagePath()); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
radio.UploadedImage = ""
|
||||||
|
return api.ds.Radio(ctx).Put(radio, "UploadedImage")
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -103,7 +103,7 @@ func (api *Router) UpdateInternetRadio(r *http.Request) (*responses.Subsonic, er
|
|||||||
Name: name,
|
Name: name,
|
||||||
}
|
}
|
||||||
|
|
||||||
err = api.ds.Radio(ctx).Put(radio)
|
err = api.ds.Radio(ctx).Put(radio, "StreamUrl", "HomePageUrl", "Name")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -73,7 +73,7 @@ func (m *MockedRadioRepo) GetAll(qo ...model.QueryOptions) (model.Radios, error)
|
|||||||
return m.All, nil
|
return m.All, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *MockedRadioRepo) Put(radio *model.Radio) error {
|
func (m *MockedRadioRepo) Put(radio *model.Radio, _ ...string) error {
|
||||||
if m.Err {
|
if m.Err {
|
||||||
return errors.New("error")
|
return errors.New("error")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useCallback, useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
CardContent,
|
CardContent,
|
||||||
@@ -29,6 +29,7 @@ import {
|
|||||||
RatingField,
|
RatingField,
|
||||||
SizeField,
|
SizeField,
|
||||||
useAlbumsPerPage,
|
useAlbumsPerPage,
|
||||||
|
useImageLoadingState,
|
||||||
} from '../common'
|
} from '../common'
|
||||||
import config from '../config'
|
import config from '../config'
|
||||||
import { formatFullDate, intersperse } from '../utils'
|
import { formatFullDate, intersperse } from '../utils'
|
||||||
@@ -220,11 +221,17 @@ const AlbumDetails = (props) => {
|
|||||||
const isXsmall = useMediaQuery((theme) => theme.breakpoints.down('xs'))
|
const isXsmall = useMediaQuery((theme) => theme.breakpoints.down('xs'))
|
||||||
const isDesktop = useMediaQuery((theme) => theme.breakpoints.up('lg'))
|
const isDesktop = useMediaQuery((theme) => theme.breakpoints.up('lg'))
|
||||||
const classes = useStyles()
|
const classes = useStyles()
|
||||||
const [isLightboxOpen, setLightboxOpen] = useState(false)
|
|
||||||
const [expanded, setExpanded] = useState(false)
|
const [expanded, setExpanded] = useState(false)
|
||||||
const [albumInfo, setAlbumInfo] = useState()
|
const [albumInfo, setAlbumInfo] = useState()
|
||||||
const [imageLoading, setImageLoading] = useState(false)
|
const {
|
||||||
const [imageError, setImageError] = useState(false)
|
imageLoading,
|
||||||
|
imageError,
|
||||||
|
isLightboxOpen,
|
||||||
|
handleImageLoad,
|
||||||
|
handleImageError,
|
||||||
|
handleOpenLightbox,
|
||||||
|
handleCloseLightbox,
|
||||||
|
} = useImageLoadingState(record.id)
|
||||||
|
|
||||||
let notes = albumInfo?.notes || record.notes
|
let notes = albumInfo?.notes || record.notes
|
||||||
|
|
||||||
@@ -247,33 +254,9 @@ const AlbumDetails = (props) => {
|
|||||||
})
|
})
|
||||||
}, [record])
|
}, [record])
|
||||||
|
|
||||||
// Reset image state when album changes
|
|
||||||
useEffect(() => {
|
|
||||||
setImageLoading(true)
|
|
||||||
setImageError(false)
|
|
||||||
}, [record.id])
|
|
||||||
|
|
||||||
const imageUrl = subsonic.getCoverArtUrl(record, 300)
|
const imageUrl = subsonic.getCoverArtUrl(record, 300)
|
||||||
const fullImageUrl = subsonic.getCoverArtUrl(record)
|
const fullImageUrl = subsonic.getCoverArtUrl(record)
|
||||||
|
|
||||||
const handleImageLoad = useCallback(() => {
|
|
||||||
setImageLoading(false)
|
|
||||||
setImageError(false)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const handleImageError = useCallback(() => {
|
|
||||||
setImageLoading(false)
|
|
||||||
setImageError(true)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const handleOpenLightbox = useCallback(() => {
|
|
||||||
if (!imageError) {
|
|
||||||
setLightboxOpen(true)
|
|
||||||
}
|
|
||||||
}, [imageError])
|
|
||||||
|
|
||||||
const handleCloseLightbox = useCallback(() => setLightboxOpen(false), [])
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className={classes.root}>
|
<Card className={classes.root}>
|
||||||
<div className={classes.cardContents}>
|
<div className={classes.cardContents}>
|
||||||
|
|||||||
@@ -6,13 +6,17 @@ import CardContent from '@material-ui/core/CardContent'
|
|||||||
import CardMedia from '@material-ui/core/CardMedia'
|
import CardMedia from '@material-ui/core/CardMedia'
|
||||||
import ArtistExternalLinks from './ArtistExternalLink'
|
import ArtistExternalLinks from './ArtistExternalLink'
|
||||||
import config from '../config'
|
import config from '../config'
|
||||||
import { LoveButton, RatingField, ImageUploadOverlay } from '../common'
|
import {
|
||||||
|
LoveButton,
|
||||||
|
RatingField,
|
||||||
|
ImageUploadOverlay,
|
||||||
|
useImageLoadingState,
|
||||||
|
} from '../common'
|
||||||
import Lightbox from 'react-image-lightbox'
|
import Lightbox from 'react-image-lightbox'
|
||||||
import ExpandInfoDialog from '../dialogs/ExpandInfoDialog'
|
import ExpandInfoDialog from '../dialogs/ExpandInfoDialog'
|
||||||
import AlbumInfo from '../album/AlbumInfo'
|
import AlbumInfo from '../album/AlbumInfo'
|
||||||
import subsonic from '../subsonic'
|
import subsonic from '../subsonic'
|
||||||
import { SafeHTML } from '../common/SafeHTML'
|
import { SafeHTML } from '../common/SafeHTML'
|
||||||
import useArtistImageState from './useArtistImageState'
|
|
||||||
|
|
||||||
const useStyles = makeStyles(
|
const useStyles = makeStyles(
|
||||||
(theme) => ({
|
(theme) => ({
|
||||||
@@ -95,7 +99,7 @@ const DesktopArtistDetails = ({ artistInfo, record, biography }) => {
|
|||||||
handleImageError,
|
handleImageError,
|
||||||
handleOpenLightbox,
|
handleOpenLightbox,
|
||||||
handleCloseLightbox,
|
handleCloseLightbox,
|
||||||
} = useArtistImageState(record.id)
|
} = useImageLoadingState(record.id)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={classes.root}>
|
<div className={classes.root}>
|
||||||
|
|||||||
@@ -4,11 +4,15 @@ import { makeStyles } from '@material-ui/core/styles'
|
|||||||
import Card from '@material-ui/core/Card'
|
import Card from '@material-ui/core/Card'
|
||||||
import CardMedia from '@material-ui/core/CardMedia'
|
import CardMedia from '@material-ui/core/CardMedia'
|
||||||
import config from '../config'
|
import config from '../config'
|
||||||
import { LoveButton, RatingField, ImageUploadOverlay } from '../common'
|
import {
|
||||||
|
LoveButton,
|
||||||
|
RatingField,
|
||||||
|
ImageUploadOverlay,
|
||||||
|
useImageLoadingState,
|
||||||
|
} from '../common'
|
||||||
import Lightbox from 'react-image-lightbox'
|
import Lightbox from 'react-image-lightbox'
|
||||||
import subsonic from '../subsonic'
|
import subsonic from '../subsonic'
|
||||||
import { SafeHTML } from '../common/SafeHTML'
|
import { SafeHTML } from '../common/SafeHTML'
|
||||||
import useArtistImageState from './useArtistImageState'
|
|
||||||
|
|
||||||
const useStyles = makeStyles(
|
const useStyles = makeStyles(
|
||||||
(theme) => ({
|
(theme) => ({
|
||||||
@@ -97,7 +101,7 @@ const MobileArtistDetails = ({ artistInfo, biography, record }) => {
|
|||||||
handleImageError,
|
handleImageError,
|
||||||
handleOpenLightbox,
|
handleOpenLightbox,
|
||||||
handleCloseLightbox,
|
handleCloseLightbox,
|
||||||
} = useArtistImageState(record.id)
|
} = useImageLoadingState(record.id)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -45,3 +45,4 @@ export * from './OverflowTooltip'
|
|||||||
export * from './useSearchRefocus'
|
export * from './useSearchRefocus'
|
||||||
export * from './ImageUploadOverlay'
|
export * from './ImageUploadOverlay'
|
||||||
export * from './CoverArtAvatar'
|
export * from './CoverArtAvatar'
|
||||||
|
export * from './useImageLoadingState'
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import { useState, useEffect, useCallback } from 'react'
|
import { useState, useEffect, useCallback } from 'react'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Manages image loading/error state and lightbox open/close for artist detail views.
|
* Manages image loading/error state and lightbox open/close.
|
||||||
* Resets when record.id changes.
|
* Resets when recordId changes.
|
||||||
*/
|
*/
|
||||||
const useArtistImageState = (recordId) => {
|
export const useImageLoadingState = (recordId) => {
|
||||||
const [imageLoading, setImageLoading] = useState(false)
|
const [imageLoading, setImageLoading] = useState(true)
|
||||||
const [imageError, setImageError] = useState(false)
|
const [imageError, setImageError] = useState(false)
|
||||||
const [isLightboxOpen, setLightboxOpen] = useState(false)
|
const [isLightboxOpen, setLightboxOpen] = useState(false)
|
||||||
|
|
||||||
@@ -42,5 +42,3 @@ const useArtistImageState = (recordId) => {
|
|||||||
handleCloseLightbox,
|
handleCloseLightbox,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default useArtistImageState
|
|
||||||
@@ -24,6 +24,8 @@ DraggableTypes.ALL.push(
|
|||||||
DraggableTypes.ARTIST,
|
DraggableTypes.ARTIST,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
export const RADIO_PLACEHOLDER_IMAGE = 'internet-radio-icon.svg'
|
||||||
|
|
||||||
export const DEFAULT_SHARE_BITRATE = 128
|
export const DEFAULT_SHARE_BITRATE = 128
|
||||||
|
|
||||||
export const BITRATE_CHOICES = [
|
export const BITRATE_CHOICES = [
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import {
|
|||||||
} from '@material-ui/core'
|
} from '@material-ui/core'
|
||||||
import { makeStyles } from '@material-ui/core/styles'
|
import { makeStyles } from '@material-ui/core/styles'
|
||||||
import { useTranslate } from 'react-admin'
|
import { useTranslate } from 'react-admin'
|
||||||
import { useCallback, useState, useEffect } from 'react'
|
|
||||||
import Lightbox from 'react-image-lightbox'
|
import Lightbox from 'react-image-lightbox'
|
||||||
import 'react-image-lightbox/style.css'
|
import 'react-image-lightbox/style.css'
|
||||||
import {
|
import {
|
||||||
@@ -17,6 +16,7 @@ import {
|
|||||||
SizeField,
|
SizeField,
|
||||||
isWritable,
|
isWritable,
|
||||||
OverflowTooltip,
|
OverflowTooltip,
|
||||||
|
useImageLoadingState,
|
||||||
} from '../common'
|
} from '../common'
|
||||||
import subsonic from '../subsonic'
|
import subsonic from '../subsonic'
|
||||||
|
|
||||||
@@ -96,37 +96,19 @@ const PlaylistDetails = (props) => {
|
|||||||
const translate = useTranslate()
|
const translate = useTranslate()
|
||||||
const classes = useStyles()
|
const classes = useStyles()
|
||||||
const isDesktop = useMediaQuery((theme) => theme.breakpoints.up('lg'))
|
const isDesktop = useMediaQuery((theme) => theme.breakpoints.up('lg'))
|
||||||
const [isLightboxOpen, setLightboxOpen] = useState(false)
|
const {
|
||||||
const [imageLoading, setImageLoading] = useState(false)
|
imageLoading,
|
||||||
const [imageError, setImageError] = useState(false)
|
imageError,
|
||||||
|
isLightboxOpen,
|
||||||
|
handleImageLoad,
|
||||||
|
handleImageError,
|
||||||
|
handleOpenLightbox,
|
||||||
|
handleCloseLightbox,
|
||||||
|
} = useImageLoadingState(record.id)
|
||||||
|
|
||||||
const imageUrl = subsonic.getCoverArtUrl(record, 300, true)
|
const imageUrl = subsonic.getCoverArtUrl(record, 300, true)
|
||||||
const fullImageUrl = subsonic.getCoverArtUrl(record)
|
const fullImageUrl = subsonic.getCoverArtUrl(record)
|
||||||
|
|
||||||
// Reset image state when playlist changes
|
|
||||||
useEffect(() => {
|
|
||||||
setImageLoading(true)
|
|
||||||
setImageError(false)
|
|
||||||
}, [record.id])
|
|
||||||
|
|
||||||
const handleImageLoad = useCallback(() => {
|
|
||||||
setImageLoading(false)
|
|
||||||
setImageError(false)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const handleImageError = useCallback(() => {
|
|
||||||
setImageLoading(false)
|
|
||||||
setImageError(true)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const handleOpenLightbox = useCallback(() => {
|
|
||||||
if (!imageError) {
|
|
||||||
setLightboxOpen(true)
|
|
||||||
}
|
|
||||||
}, [imageError])
|
|
||||||
|
|
||||||
const handleCloseLightbox = useCallback(() => setLightboxOpen(false), [])
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className={classes.root}>
|
<Card className={classes.root}>
|
||||||
<div className={classes.cardContents}>
|
<div className={classes.cardContents}>
|
||||||
|
|||||||
@@ -6,8 +6,37 @@ import {
|
|||||||
TextInput,
|
TextInput,
|
||||||
useTranslate,
|
useTranslate,
|
||||||
} from 'react-admin'
|
} from 'react-admin'
|
||||||
|
import { CardMedia } from '@material-ui/core'
|
||||||
|
import { makeStyles } from '@material-ui/core/styles'
|
||||||
import { urlValidate } from '../utils/validations'
|
import { urlValidate } from '../utils/validations'
|
||||||
import { Title } from '../common'
|
import { Title, ImageUploadOverlay, useImageLoadingState } from '../common'
|
||||||
|
import subsonic from '../subsonic'
|
||||||
|
import { RADIO_PLACEHOLDER_IMAGE } from '../consts'
|
||||||
|
|
||||||
|
const useStyles = makeStyles({
|
||||||
|
coverParent: {
|
||||||
|
display: 'inline-flex',
|
||||||
|
position: 'relative',
|
||||||
|
width: '8rem',
|
||||||
|
height: '8rem',
|
||||||
|
marginBottom: '1em',
|
||||||
|
},
|
||||||
|
cover: {
|
||||||
|
width: '8rem',
|
||||||
|
height: '8rem',
|
||||||
|
objectFit: 'cover',
|
||||||
|
cursor: 'pointer',
|
||||||
|
transition: 'opacity 0.3s ease-in-out',
|
||||||
|
},
|
||||||
|
coverLoading: {
|
||||||
|
opacity: 0.5,
|
||||||
|
},
|
||||||
|
placeholder: {
|
||||||
|
width: '8rem',
|
||||||
|
height: '8rem',
|
||||||
|
objectFit: 'contain',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
const RadioTitle = ({ record }) => {
|
const RadioTitle = ({ record }) => {
|
||||||
const translate = useTranslate()
|
const translate = useTranslate()
|
||||||
@@ -21,6 +50,7 @@ const RadioEdit = (props) => {
|
|||||||
return (
|
return (
|
||||||
<Edit title={<RadioTitle />} {...props}>
|
<Edit title={<RadioTitle />} {...props}>
|
||||||
<SimpleForm variant="outlined" {...props}>
|
<SimpleForm variant="outlined" {...props}>
|
||||||
|
<RadioCoverArt />
|
||||||
<TextInput source="name" validate={[required()]} />
|
<TextInput source="name" validate={[required()]} />
|
||||||
<TextInput
|
<TextInput
|
||||||
type="url"
|
type="url"
|
||||||
@@ -41,4 +71,39 @@ const RadioEdit = (props) => {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const RadioCoverArt = ({ record }) => {
|
||||||
|
const classes = useStyles()
|
||||||
|
const { imageLoading, handleImageLoad, handleImageError } =
|
||||||
|
useImageLoadingState(record?.id)
|
||||||
|
|
||||||
|
if (!record) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={classes.coverParent}>
|
||||||
|
{record.uploadedImage ? (
|
||||||
|
<CardMedia
|
||||||
|
component="img"
|
||||||
|
src={subsonic.getCoverArtUrl(record, 300, true)}
|
||||||
|
className={`${classes.cover} ${imageLoading ? classes.coverLoading : ''}`}
|
||||||
|
onLoad={handleImageLoad}
|
||||||
|
onError={handleImageError}
|
||||||
|
title={record.name}
|
||||||
|
alt={record.name}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<img
|
||||||
|
src={RADIO_PLACEHOLDER_IMAGE}
|
||||||
|
className={classes.placeholder}
|
||||||
|
alt={record.name}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<ImageUploadOverlay
|
||||||
|
entityType="radio"
|
||||||
|
entityId={record.id}
|
||||||
|
hasUploadedImage={!!record.uploadedImage}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export default RadioEdit
|
export default RadioEdit
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { makeStyles, useMediaQuery } from '@material-ui/core'
|
import { Avatar, makeStyles, useMediaQuery } from '@material-ui/core'
|
||||||
import React, { cloneElement } from 'react'
|
import React, { cloneElement } from 'react'
|
||||||
import {
|
import {
|
||||||
CreateButton,
|
CreateButton,
|
||||||
@@ -16,9 +16,11 @@ import {
|
|||||||
} from 'react-admin'
|
} from 'react-admin'
|
||||||
import { List } from '../common'
|
import { List } from '../common'
|
||||||
import { ToggleFieldsMenu, useSelectedFields } from '../common'
|
import { ToggleFieldsMenu, useSelectedFields } from '../common'
|
||||||
|
import subsonic from '../subsonic'
|
||||||
import { StreamField } from './StreamField'
|
import { StreamField } from './StreamField'
|
||||||
import { setTrack } from '../actions'
|
import { setTrack } from '../actions'
|
||||||
import { songFromRadio } from './helper'
|
import { songFromRadio } from './helper'
|
||||||
|
import { RADIO_PLACEHOLDER_IMAGE } from '../consts'
|
||||||
import { useDispatch } from 'react-redux'
|
import { useDispatch } from 'react-redux'
|
||||||
|
|
||||||
const useStyles = makeStyles({
|
const useStyles = makeStyles({
|
||||||
@@ -73,6 +75,19 @@ const RadioListActions = ({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const avatarStyle = { width: 40, height: 40 }
|
||||||
|
|
||||||
|
const CoverArtField = ({ record }) => {
|
||||||
|
if (!record) return null
|
||||||
|
const src = record.uploadedImage
|
||||||
|
? subsonic.getCoverArtUrl(record, 40, true)
|
||||||
|
: RADIO_PLACEHOLDER_IMAGE
|
||||||
|
return (
|
||||||
|
<Avatar src={src} variant="rounded" style={avatarStyle} alt={record.name} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
CoverArtField.defaultProps = { label: '' }
|
||||||
|
|
||||||
const RadioList = ({ permissions, ...props }) => {
|
const RadioList = ({ permissions, ...props }) => {
|
||||||
const classes = useStyles()
|
const classes = useStyles()
|
||||||
const isXsmall = useMediaQuery((theme) => theme.breakpoints.down('xs'))
|
const isXsmall = useMediaQuery((theme) => theme.breakpoints.down('xs'))
|
||||||
@@ -80,6 +95,7 @@ const RadioList = ({ permissions, ...props }) => {
|
|||||||
const isAdmin = permissions === 'admin'
|
const isAdmin = permissions === 'admin'
|
||||||
|
|
||||||
const toggleableFields = {
|
const toggleableFields = {
|
||||||
|
coverArt: <CoverArtField source="id" sortable={false} />,
|
||||||
name: <TextField source="name" />,
|
name: <TextField source="name" />,
|
||||||
homePageUrl: (
|
homePageUrl: (
|
||||||
<UrlField
|
<UrlField
|
||||||
@@ -97,7 +113,7 @@ const RadioList = ({ permissions, ...props }) => {
|
|||||||
const columns = useSelectedFields({
|
const columns = useSelectedFields({
|
||||||
resource: 'radio',
|
resource: 'radio',
|
||||||
columns: toggleableFields,
|
columns: toggleableFields,
|
||||||
defaultOff: ['createdAt'],
|
defaultOff: ['streamUrl', 'createdAt'],
|
||||||
})
|
})
|
||||||
|
|
||||||
const handleRowClick = async (id, basePath, record) => {
|
const handleRowClick = async (id, basePath, record) => {
|
||||||
@@ -117,6 +133,7 @@ const RadioList = ({ permissions, ...props }) => {
|
|||||||
>
|
>
|
||||||
{isXsmall ? (
|
{isXsmall ? (
|
||||||
<SimpleList
|
<SimpleList
|
||||||
|
leftAvatar={(r) => <CoverArtField record={r} />}
|
||||||
leftIcon={(r) => (
|
leftIcon={(r) => (
|
||||||
<StreamField
|
<StreamField
|
||||||
record={r}
|
record={r}
|
||||||
|
|||||||
+10
-2
@@ -1,16 +1,24 @@
|
|||||||
|
import subsonic from '../subsonic'
|
||||||
|
import { RADIO_PLACEHOLDER_IMAGE } from '../consts'
|
||||||
|
|
||||||
export async function songFromRadio(radio) {
|
export async function songFromRadio(radio) {
|
||||||
if (!radio) {
|
if (!radio) {
|
||||||
return undefined
|
return undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
let cover = 'internet-radio-icon.svg'
|
let cover = RADIO_PLACEHOLDER_IMAGE
|
||||||
|
if (radio.uploadedImage) {
|
||||||
|
cover = subsonic.getCoverArtUrl(radio, 300, true)
|
||||||
|
} else {
|
||||||
|
// Try favicon as fallback
|
||||||
try {
|
try {
|
||||||
const url = new URL(radio.homePageUrl ?? radio.streamUrl)
|
const url = new URL(radio.homePageUrl ?? radio.streamUrl)
|
||||||
url.pathname = '/favicon.ico'
|
url.pathname = '/favicon.ico'
|
||||||
await resourceExists(url)
|
await resourceExists(url)
|
||||||
cover = url.toString()
|
cover = url.toString()
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
// No cover available
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -86,6 +86,9 @@ const getCoverArtUrl = (record, size, square) => {
|
|||||||
} else if (record.sync !== undefined) {
|
} else if (record.sync !== undefined) {
|
||||||
// This is a playlist
|
// This is a playlist
|
||||||
return baseUrl(url('getCoverArt', 'pl-' + record.id, options))
|
return baseUrl(url('getCoverArt', 'pl-' + record.id, options))
|
||||||
|
} else if (record.streamUrl !== undefined) {
|
||||||
|
// This is a radio station
|
||||||
|
return baseUrl(url('getCoverArt', 'ra-' + record.id, options))
|
||||||
} else {
|
} else {
|
||||||
return baseUrl(url('getCoverArt', 'ar-' + record.id, options))
|
return baseUrl(url('getCoverArt', 'ar-' + record.id, options))
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user