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
+7 -3
View File
@@ -6,13 +6,17 @@ import CardContent from '@material-ui/core/CardContent'
import CardMedia from '@material-ui/core/CardMedia'
import ArtistExternalLinks from './ArtistExternalLink'
import config from '../config'
import { LoveButton, RatingField, ImageUploadOverlay } from '../common'
import {
LoveButton,
RatingField,
ImageUploadOverlay,
useImageLoadingState,
} from '../common'
import Lightbox from 'react-image-lightbox'
import ExpandInfoDialog from '../dialogs/ExpandInfoDialog'
import AlbumInfo from '../album/AlbumInfo'
import subsonic from '../subsonic'
import { SafeHTML } from '../common/SafeHTML'
import useArtistImageState from './useArtistImageState'
const useStyles = makeStyles(
(theme) => ({
@@ -95,7 +99,7 @@ const DesktopArtistDetails = ({ artistInfo, record, biography }) => {
handleImageError,
handleOpenLightbox,
handleCloseLightbox,
} = useArtistImageState(record.id)
} = useImageLoadingState(record.id)
return (
<div className={classes.root}>
+7 -3
View File
@@ -4,11 +4,15 @@ import { makeStyles } from '@material-ui/core/styles'
import Card from '@material-ui/core/Card'
import CardMedia from '@material-ui/core/CardMedia'
import config from '../config'
import { LoveButton, RatingField, ImageUploadOverlay } from '../common'
import {
LoveButton,
RatingField,
ImageUploadOverlay,
useImageLoadingState,
} from '../common'
import Lightbox from 'react-image-lightbox'
import subsonic from '../subsonic'
import { SafeHTML } from '../common/SafeHTML'
import useArtistImageState from './useArtistImageState'
const useStyles = makeStyles(
(theme) => ({
@@ -97,7 +101,7 @@ const MobileArtistDetails = ({ artistInfo, biography, record }) => {
handleImageError,
handleOpenLightbox,
handleCloseLightbox,
} = useArtistImageState(record.id)
} = useImageLoadingState(record.id)
return (
<>
-46
View File
@@ -1,46 +0,0 @@
import { useState, useEffect, useCallback } from 'react'
/**
* Manages image loading/error state and lightbox open/close for artist detail views.
* Resets when record.id changes.
*/
const useArtistImageState = (recordId) => {
const [imageLoading, setImageLoading] = useState(false)
const [imageError, setImageError] = useState(false)
const [isLightboxOpen, setLightboxOpen] = useState(false)
useEffect(() => {
setImageLoading(true)
setImageError(false)
}, [recordId])
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 {
imageLoading,
imageError,
isLightboxOpen,
handleImageLoad,
handleImageError,
handleOpenLightbox,
handleCloseLightbox,
}
}
export default useArtistImageState