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
+19 -2
View File
@@ -1,4 +1,4 @@
import { makeStyles, useMediaQuery } from '@material-ui/core'
import { Avatar, makeStyles, useMediaQuery } from '@material-ui/core'
import React, { cloneElement } from 'react'
import {
CreateButton,
@@ -16,9 +16,11 @@ import {
} from 'react-admin'
import { List } from '../common'
import { ToggleFieldsMenu, useSelectedFields } from '../common'
import subsonic from '../subsonic'
import { StreamField } from './StreamField'
import { setTrack } from '../actions'
import { songFromRadio } from './helper'
import { RADIO_PLACEHOLDER_IMAGE } from '../consts'
import { useDispatch } from 'react-redux'
const useStyles = makeStyles({
@@ -73,6 +75,19 @@ const RadioListActions = ({
)
}
const avatarStyle = { width: 40, height: 40 }
const CoverArtField = ({ record }) => {
if (!record) return null
const src = record.uploadedImage
? subsonic.getCoverArtUrl(record, 40, true)
: RADIO_PLACEHOLDER_IMAGE
return (
<Avatar src={src} variant="rounded" style={avatarStyle} alt={record.name} />
)
}
CoverArtField.defaultProps = { label: '' }
const RadioList = ({ permissions, ...props }) => {
const classes = useStyles()
const isXsmall = useMediaQuery((theme) => theme.breakpoints.down('xs'))
@@ -80,6 +95,7 @@ const RadioList = ({ permissions, ...props }) => {
const isAdmin = permissions === 'admin'
const toggleableFields = {
coverArt: <CoverArtField source="id" sortable={false} />,
name: <TextField source="name" />,
homePageUrl: (
<UrlField
@@ -97,7 +113,7 @@ const RadioList = ({ permissions, ...props }) => {
const columns = useSelectedFields({
resource: 'radio',
columns: toggleableFields,
defaultOff: ['createdAt'],
defaultOff: ['streamUrl', 'createdAt'],
})
const handleRowClick = async (id, basePath, record) => {
@@ -117,6 +133,7 @@ const RadioList = ({ permissions, ...props }) => {
>
{isXsmall ? (
<SimpleList
leftAvatar={(r) => <CoverArtField record={r} />}
leftIcon={(r) => (
<StreamField
record={r}