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:
Deluan Quintão
2026-03-18 18:57:33 -04:00
committed by GitHub
parent 3f7226d253
commit ba8d427890
25 changed files with 450 additions and 109 deletions
+10
View File
@@ -23,6 +23,7 @@ var (
KindAlbumArtwork = Kind{"al", "album"}
KindPlaylistArtwork = Kind{"pl", "playlist"}
KindDiscArtwork = Kind{"dc", "disc"}
KindRadioArtwork = Kind{"ra", "radio"}
)
var artworkKindMap = map[string]Kind{
@@ -31,6 +32,7 @@ var artworkKindMap = map[string]Kind{
KindAlbumArtwork.prefix: KindAlbumArtwork,
KindPlaylistArtwork.prefix: KindPlaylistArtwork,
KindDiscArtwork.prefix: KindDiscArtwork,
KindRadioArtwork.prefix: KindRadioArtwork,
}
type ArtworkID struct {
@@ -139,3 +141,11 @@ func artworkIDFromArtist(ar Artist) ArtworkID {
ID: ar.ID,
}
}
func artworkIDFromRadio(r Radio) ArtworkID {
return ArtworkID{
Kind: KindRadioArtwork,
ID: r.ID,
LastUpdate: r.UpdatedAt,
}
}
+4
View File
@@ -22,5 +22,9 @@ func GetEntityByID(ctx context.Context, ds DataStore, id string) (any, error) {
if err == nil {
return mf, nil
}
r, err := ds.Radio(ctx).Get(id)
if err == nil {
return r, nil
}
return nil, err
}
+21 -8
View File
@@ -1,14 +1,27 @@
package model
import "time"
import (
"time"
"github.com/navidrome/navidrome/consts"
)
type Radio struct {
ID string `structs:"id" json:"id"`
StreamUrl string `structs:"stream_url" json:"streamUrl"`
Name string `structs:"name" json:"name"`
HomePageUrl string `structs:"home_page_url" json:"homePageUrl"`
CreatedAt time.Time `structs:"created_at" json:"createdAt"`
UpdatedAt time.Time `structs:"updated_at" json:"updatedAt"`
ID string `structs:"id" json:"id"`
StreamUrl string `structs:"stream_url" json:"streamUrl"`
Name string `structs:"name" json:"name"`
HomePageUrl string `structs:"home_page_url" json:"homePageUrl"`
UploadedImage string `structs:"uploaded_image" json:"uploadedImage,omitempty"`
CreatedAt time.Time `structs:"created_at" json:"createdAt"`
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
@@ -19,5 +32,5 @@ type RadioRepository interface {
Delete(id string) error
Get(id string) (*Radio, error)
GetAll(options ...QueryOptions) (Radios, error)
Put(u *Radio) error
Put(u *Radio, colsToUpdate ...string) error
}
+42
View File
@@ -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")))
})
})
})