ba8d427890
* 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>
152 lines
3.2 KiB
Go
152 lines
3.2 KiB
Go
package model
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
type Kind struct {
|
|
prefix string
|
|
name string
|
|
}
|
|
|
|
func (k Kind) String() string {
|
|
return k.name
|
|
}
|
|
|
|
var (
|
|
KindMediaFileArtwork = Kind{"mf", "media_file"}
|
|
KindArtistArtwork = Kind{"ar", "artist"}
|
|
KindAlbumArtwork = Kind{"al", "album"}
|
|
KindPlaylistArtwork = Kind{"pl", "playlist"}
|
|
KindDiscArtwork = Kind{"dc", "disc"}
|
|
KindRadioArtwork = Kind{"ra", "radio"}
|
|
)
|
|
|
|
var artworkKindMap = map[string]Kind{
|
|
KindMediaFileArtwork.prefix: KindMediaFileArtwork,
|
|
KindArtistArtwork.prefix: KindArtistArtwork,
|
|
KindAlbumArtwork.prefix: KindAlbumArtwork,
|
|
KindPlaylistArtwork.prefix: KindPlaylistArtwork,
|
|
KindDiscArtwork.prefix: KindDiscArtwork,
|
|
KindRadioArtwork.prefix: KindRadioArtwork,
|
|
}
|
|
|
|
type ArtworkID struct {
|
|
Kind Kind
|
|
ID string
|
|
LastUpdate time.Time
|
|
}
|
|
|
|
func (id ArtworkID) String() string {
|
|
if id.ID == "" {
|
|
return ""
|
|
}
|
|
s := fmt.Sprintf("%s-%s", id.Kind.prefix, id.ID)
|
|
if lu := id.LastUpdate.Unix(); lu > 0 {
|
|
return fmt.Sprintf("%s_%x", s, lu)
|
|
}
|
|
return s + "_0"
|
|
}
|
|
|
|
func NewArtworkID(kind Kind, id string, lastUpdate *time.Time) ArtworkID {
|
|
artID := ArtworkID{kind, id, time.Time{}}
|
|
if lastUpdate != nil {
|
|
artID.LastUpdate = *lastUpdate
|
|
}
|
|
return artID
|
|
}
|
|
|
|
func ParseArtworkID(id string) (ArtworkID, error) {
|
|
parts := strings.SplitN(id, "-", 2)
|
|
if len(parts) != 2 {
|
|
return ArtworkID{}, errors.New("invalid artwork id")
|
|
}
|
|
kind, ok := artworkKindMap[parts[0]]
|
|
if !ok {
|
|
return ArtworkID{}, errors.New("invalid artwork kind")
|
|
}
|
|
parsedID := ArtworkID{
|
|
Kind: kind,
|
|
ID: parts[1],
|
|
}
|
|
parts = strings.SplitN(parts[1], "_", 2)
|
|
if len(parts) == 2 {
|
|
if parts[1] != "0" {
|
|
lastUpdate, err := strconv.ParseInt(parts[1], 16, 64)
|
|
if err != nil {
|
|
return ArtworkID{}, err
|
|
}
|
|
parsedID.LastUpdate = time.Unix(lastUpdate, 0)
|
|
}
|
|
parsedID.ID = parts[0]
|
|
}
|
|
return parsedID, nil
|
|
}
|
|
|
|
func MustParseArtworkID(id string) ArtworkID {
|
|
artID, err := ParseArtworkID(id)
|
|
if err != nil {
|
|
panic(artID)
|
|
}
|
|
return artID
|
|
}
|
|
|
|
func DiscArtworkID(albumID string, discNumber int) string {
|
|
return fmt.Sprintf("%s:%d", albumID, discNumber)
|
|
}
|
|
|
|
func ParseDiscArtworkID(id string) (albumID string, discNumber int, err error) {
|
|
parts := strings.SplitN(id, ":", 2)
|
|
if len(parts) != 2 || parts[1] == "" {
|
|
return "", 0, errors.New("invalid disc artwork id")
|
|
}
|
|
num, err := strconv.Atoi(parts[1])
|
|
if err != nil {
|
|
return "", 0, fmt.Errorf("invalid disc number in artwork id: %w", err)
|
|
}
|
|
return parts[0], num, nil
|
|
}
|
|
|
|
func artworkIDFromAlbum(al Album) ArtworkID {
|
|
return ArtworkID{
|
|
Kind: KindAlbumArtwork,
|
|
ID: al.ID,
|
|
LastUpdate: al.UpdatedAt,
|
|
}
|
|
}
|
|
|
|
func artworkIDFromMediaFile(mf MediaFile) ArtworkID {
|
|
return ArtworkID{
|
|
Kind: KindMediaFileArtwork,
|
|
ID: mf.ID,
|
|
LastUpdate: mf.UpdatedAt,
|
|
}
|
|
}
|
|
|
|
func artworkIDFromPlaylist(pls Playlist) ArtworkID {
|
|
return ArtworkID{
|
|
Kind: KindPlaylistArtwork,
|
|
ID: pls.ID,
|
|
LastUpdate: pls.UpdatedAt,
|
|
}
|
|
}
|
|
|
|
func artworkIDFromArtist(ar Artist) ArtworkID {
|
|
return ArtworkID{
|
|
Kind: KindArtistArtwork,
|
|
ID: ar.ID,
|
|
}
|
|
}
|
|
|
|
func artworkIDFromRadio(r Radio) ArtworkID {
|
|
return ArtworkID{
|
|
Kind: KindRadioArtwork,
|
|
ID: r.ID,
|
|
LastUpdate: r.UpdatedAt,
|
|
}
|
|
}
|