Files
navidrome/ui/src/subsonic/index.js
T
Deluan Quintão ba8d427890 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

153 lines
3.8 KiB
JavaScript

import { baseUrl } from '../utils'
import { httpClient } from '../dataProvider'
const url = (command, id, options) => {
const username = localStorage.getItem('username')
const token = localStorage.getItem('subsonic-token')
const salt = localStorage.getItem('subsonic-salt')
if (!username || !token || !salt) {
return ''
}
const params = new URLSearchParams()
params.append('u', username)
params.append('t', token)
params.append('s', salt)
params.append('f', 'json')
params.append('v', '1.8.0')
params.append('c', 'NavidromeUI')
id && params.append('id', id)
if (options) {
if (options.ts) {
options['_'] = new Date().getTime()
delete options.ts
}
Object.keys(options).forEach((k) => {
const value = options[k]
// Handle array parameters by appending each value separately
if (Array.isArray(value)) {
value.forEach((v) => params.append(k, v))
} else {
params.append(k, value)
}
})
}
return `/rest/${command}?${params.toString()}`
}
const ping = () => httpClient(url('ping'))
const scrobble = (id, time, submission = true, position = null) =>
httpClient(
url('scrobble', id, {
...(submission && time && { time }),
submission,
...(!submission && position !== null && { position }),
}),
)
const nowPlaying = (id, position = null) => scrobble(id, null, false, position)
const star = (id) => httpClient(url('star', id))
const unstar = (id) => httpClient(url('unstar', id))
const setRating = (id, rating) => httpClient(url('setRating', id, { rating }))
const download = (id, format = 'raw', bitrate = '0') =>
(window.location.href = baseUrl(url('download', id, { format, bitrate })))
const startScan = (options) => httpClient(url('startScan', null, options))
const getScanStatus = () => httpClient(url('getScanStatus'))
const getNowPlaying = () => httpClient(url('getNowPlaying'))
const getAvatarUrl = (username, size) =>
baseUrl(
url('getAvatar', null, {
username,
...(size && { size }),
}),
)
const getCoverArtUrl = (record, size, square) => {
const options = {
...(record.updatedAt && { _: record.updatedAt }),
...(size && { size }),
...(square && { square }),
}
// TODO Move this logic to server
if (record.album) {
return baseUrl(url('getCoverArt', 'mf-' + record.id, options))
} else if (record.albumArtist) {
return baseUrl(url('getCoverArt', 'al-' + record.id, options))
} else if (record.sync !== undefined) {
// This is a playlist
return baseUrl(url('getCoverArt', 'pl-' + record.id, options))
} else if (record.streamUrl !== undefined) {
// This is a radio station
return baseUrl(url('getCoverArt', 'ra-' + record.id, options))
} else {
return baseUrl(url('getCoverArt', 'ar-' + record.id, options))
}
}
const getDiscCoverArtUrl = (albumId, discNumber, updatedAt, size) => {
const options = {
...(updatedAt && { _: updatedAt }),
...(size && { size }),
}
return baseUrl(
url('getCoverArt', 'dc-' + albumId + ':' + discNumber, options),
)
}
const getArtistInfo = (id) => {
return httpClient(url('getArtistInfo', id))
}
const getAlbumInfo = (id) => {
return httpClient(url('getAlbumInfo', id))
}
const getSimilarSongs2 = (id, count = 100) => {
return httpClient(url('getSimilarSongs2', id, { count }))
}
const getTopSongs = (artist, count = 50) => {
return httpClient(url('getTopSongs', null, { artist, count }))
}
const streamUrl = (id, options) => {
return baseUrl(
url('stream', id, {
ts: true,
...options,
}),
)
}
export default {
url,
ping,
scrobble,
nowPlaying,
download,
star,
unstar,
setRating,
startScan,
getScanStatus,
getNowPlaying,
getCoverArtUrl,
getDiscCoverArtUrl,
getAvatarUrl,
streamUrl,
getAlbumInfo,
getArtistInfo,
getTopSongs,
getSimilarSongs2,
}