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
+2
View File
@@ -124,6 +124,8 @@ func (a *artwork) getArtworkReader(ctx context.Context, artID model.ArtworkID, s
artReader, err = newPlaylistArtworkReader(ctx, a, artID)
case model.KindDiscArtwork:
artReader, err = newDiscArtworkReader(ctx, a, artID)
case model.KindRadioArtwork:
artReader, err = newRadioArtworkReader(ctx, a, artID)
default:
return nil, ErrUnavailable
}
+40
View File
@@ -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())
}
+84
View File
@@ -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())
})
})
})
})