navidrome/core/artwork/artwork.go
Deluan Quintão ba8d427890
Some checks failed
Pipeline: Test, Lint, Build / Lint Go code (push) Failing after 11s
Pipeline: Test, Lint, Build / Get version info (push) Failing after 12s
Pipeline: Test, Lint, Build / Test Go code (push) Failing after 4s
Pipeline: Test, Lint, Build / Test JS code (push) Failing after 3s
Pipeline: Test, Lint, Build / Check Docker configuration (push) Successful in 2s
Pipeline: Test, Lint, Build / Lint i18n files (push) Failing after 3s
Pipeline: Test, Lint, Build / Build (push) Has been skipped
Pipeline: Test, Lint, Build / Build-1 (push) Has been skipped
Pipeline: Test, Lint, Build / Build-2 (push) Has been skipped
Pipeline: Test, Lint, Build / Build-3 (push) Has been skipped
Pipeline: Test, Lint, Build / Build-4 (push) Has been skipped
Pipeline: Test, Lint, Build / Build-5 (push) Has been skipped
Pipeline: Test, Lint, Build / Build-6 (push) Has been skipped
Pipeline: Test, Lint, Build / Build-7 (push) Has been skipped
Pipeline: Test, Lint, Build / Build-8 (push) Has been skipped
Pipeline: Test, Lint, Build / Build-9 (push) Has been skipped
Pipeline: Test, Lint, Build / Build-10 (push) Has been skipped
Pipeline: Test, Lint, Build / Build Windows installers (push) Has been skipped
Pipeline: Test, Lint, Build / Package/Release (push) Has been skipped
Pipeline: Test, Lint, Build / Upload Linux PKG (push) Has been skipped
Pipeline: Test, Lint, Build / Push to GHCR (push) Has been skipped
Pipeline: Test, Lint, Build / Push to Docker Hub (push) Has been skipped
Pipeline: Test, Lint, Build / Cleanup digest artifacts (push) Has been skipped
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>
2026-03-18 18:57:33 -04:00

134 lines
4.2 KiB
Go

package artwork
import (
"context"
"errors"
_ "image/gif"
"io"
"time"
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/core/external"
"github.com/navidrome/navidrome/core/ffmpeg"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/resources"
"github.com/navidrome/navidrome/utils/cache"
_ "golang.org/x/image/webp"
)
var ErrUnavailable = errors.New("artwork unavailable")
type Artwork interface {
Get(ctx context.Context, artID model.ArtworkID, size int, square bool) (io.ReadCloser, time.Time, error)
GetOrPlaceholder(ctx context.Context, id string, size int, square bool) (io.ReadCloser, time.Time, error)
}
func NewArtwork(ds model.DataStore, cache cache.FileCache, ffmpeg ffmpeg.FFmpeg, provider external.Provider) Artwork {
return &artwork{ds: ds, cache: cache, ffmpeg: ffmpeg, provider: provider}
}
type artwork struct {
ds model.DataStore
cache cache.FileCache
ffmpeg ffmpeg.FFmpeg
provider external.Provider
}
type artworkReader interface {
cache.Item
LastUpdated() time.Time
Reader(ctx context.Context) (io.ReadCloser, string, error)
}
func (a *artwork) GetOrPlaceholder(ctx context.Context, id string, size int, square bool) (reader io.ReadCloser, lastUpdate time.Time, err error) {
artID, err := a.getArtworkId(ctx, id)
if err == nil {
reader, lastUpdate, err = a.Get(ctx, artID, size, square)
}
if errors.Is(err, ErrUnavailable) {
if artID.Kind == model.KindArtistArtwork {
reader, _ = resources.FS().Open(consts.PlaceholderArtistArt)
} else {
reader, _ = resources.FS().Open(consts.PlaceholderAlbumArt)
}
return reader, consts.ServerStart, nil
}
return reader, lastUpdate, err
}
func (a *artwork) Get(ctx context.Context, artID model.ArtworkID, size int, square bool) (reader io.ReadCloser, lastUpdate time.Time, err error) {
artReader, err := a.getArtworkReader(ctx, artID, size, square)
if err != nil {
return nil, time.Time{}, err
}
r, err := a.cache.Get(ctx, artReader)
if err != nil {
if !errors.Is(err, context.Canceled) && !errors.Is(err, ErrUnavailable) {
log.Error(ctx, "Error accessing image cache", "id", artID, "size", size, err)
}
return nil, time.Time{}, err
}
return r, artReader.LastUpdated(), nil
}
type coverArtGetter interface {
CoverArtID() model.ArtworkID
}
func (a *artwork) getArtworkId(ctx context.Context, id string) (model.ArtworkID, error) {
if id == "" {
return model.ArtworkID{}, ErrUnavailable
}
artID, err := model.ParseArtworkID(id)
if err == nil {
return artID, nil
}
log.Trace(ctx, "ArtworkID invalid. Trying to figure out kind based on the ID", "id", id)
entity, err := model.GetEntityByID(ctx, a.ds, id)
if err != nil {
return model.ArtworkID{}, err
}
if e, ok := entity.(coverArtGetter); ok {
artID = e.CoverArtID()
}
switch e := entity.(type) {
case *model.Artist:
log.Trace(ctx, "ID is for an Artist", "id", id, "name", e.Name, "artist", e.Name)
case *model.Album:
log.Trace(ctx, "ID is for an Album", "id", id, "name", e.Name, "artist", e.AlbumArtist)
case *model.MediaFile:
log.Trace(ctx, "ID is for a MediaFile", "id", id, "title", e.Title, "album", e.Album)
case *model.Playlist:
log.Trace(ctx, "ID is for a Playlist", "id", id, "name", e.Name)
}
return artID, nil
}
func (a *artwork) getArtworkReader(ctx context.Context, artID model.ArtworkID, size int, square bool) (artworkReader, error) {
var artReader artworkReader
var err error
if size > 0 || square {
artReader, err = resizedFromOriginal(ctx, a, artID, size, square)
} else {
switch artID.Kind {
case model.KindArtistArtwork:
artReader, err = newArtistArtworkReader(ctx, a, artID, a.provider)
case model.KindAlbumArtwork:
artReader, err = newAlbumArtworkReader(ctx, a, artID, a.provider)
case model.KindMediaFileArtwork:
artReader, err = newMediafileArtworkReader(ctx, a, artID)
case model.KindPlaylistArtwork:
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
}
}
return artReader, err
}