diff --git a/ui/package-lock.json b/ui/package-lock.json index 0e6183720..a9b83d76e 100644 --- a/ui/package-lock.json +++ b/ui/package-lock.json @@ -117,7 +117,6 @@ "node_modules/@babel/core": { "version": "7.28.6", "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/generator": "^7.28.6", @@ -1530,7 +1529,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" }, @@ -1552,7 +1550,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" } @@ -2296,7 +2293,6 @@ "node_modules/@jsonforms/core": { "version": "2.5.2", "license": "MIT", - "peer": true, "dependencies": { "@types/json-schema": "^7.0.3", "ajv": "^6.10.2", @@ -2340,7 +2336,6 @@ "node_modules/@jsonforms/react": { "version": "2.5.2", "license": "MIT", - "peer": true, "dependencies": { "lodash": "^4.17.15", "object-hash": "^2.0.0" @@ -2353,7 +2348,6 @@ "node_modules/@material-ui/core": { "version": "4.12.4", "license": "MIT", - "peer": true, "dependencies": { "@babel/runtime": "^7.4.4", "@material-ui/styles": "^4.11.5", @@ -2396,7 +2390,6 @@ "node_modules/@material-ui/icons": { "version": "4.11.3", "license": "MIT", - "peer": true, "dependencies": { "@babel/runtime": "^7.4.4" }, @@ -2794,7 +2787,9 @@ "license": "MIT" }, "node_modules/@rollup/pluginutils/node_modules/picomatch": { - "version": "4.0.3", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "license": "MIT", "engines": { "node": ">=12" @@ -3013,7 +3008,6 @@ "node_modules/@types/hoist-non-react-statics": { "version": "3.3.7", "license": "MIT", - "peer": true, "dependencies": { "hoist-non-react-statics": "^3.3.0" }, @@ -3054,7 +3048,6 @@ "version": "24.10.9", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~7.16.0" } @@ -3070,7 +3063,6 @@ "node_modules/@types/react": { "version": "17.0.90", "license": "MIT", - "peer": true, "dependencies": { "@types/prop-types": "*", "@types/scheduler": "^0.16", @@ -3206,7 +3198,6 @@ "version": "6.21.0", "dev": true, "license": "BSD-2-Clause", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "6.21.0", "@typescript-eslint/types": "6.21.0", @@ -3504,7 +3495,6 @@ "node_modules/acorn": { "version": "8.15.0", "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -4076,7 +4066,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -4390,7 +4379,6 @@ "node_modules/connected-react-router": { "version": "6.9.3", "license": "MIT", - "peer": true, "dependencies": { "lodash.isequalwith": "^4.4.0", "prop-types": "^15.7.2" @@ -5142,7 +5130,6 @@ "version": "8.57.1", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", @@ -5640,7 +5627,6 @@ "node_modules/final-form": { "version": "4.20.10", "license": "MIT", - "peer": true, "dependencies": { "@babel/runtime": "^7.10.0" }, @@ -5655,7 +5641,6 @@ "node_modules/final-form-arrays": { "version": "3.1.0", "license": "MIT", - "peer": true, "peerDependencies": { "final-form": "^4.20.8" } @@ -6004,7 +5989,6 @@ "version": "20.3.3", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/node": ">=20.0.0", "@types/whatwg-mimetype": "^3.0.2", @@ -6100,7 +6084,6 @@ "node_modules/history": { "version": "4.10.1", "license": "MIT", - "peer": true, "dependencies": { "@babel/runtime": "^7.1.2", "loose-envify": "^1.2.0", @@ -7433,7 +7416,6 @@ "node_modules/moment": { "version": "2.30.1", "license": "MIT", - "peer": true, "engines": { "node": "*" } @@ -7932,7 +7914,9 @@ "license": "ISC" }, "node_modules/picomatch": { - "version": "2.3.1", + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", "license": "MIT", "engines": { "node": ">=8.6" @@ -8039,7 +8023,6 @@ "node_modules/prop-types": { "version": "15.8.1", "license": "MIT", - "peer": true, "dependencies": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", @@ -8115,7 +8098,6 @@ "node_modules/ra-core": { "version": "3.19.12", "license": "MIT", - "peer": true, "dependencies": { "classnames": "~2.3.1", "date-fns": "^1.29.0", @@ -8469,7 +8451,6 @@ "node_modules/react": { "version": "17.0.2", "license": "MIT", - "peer": true, "dependencies": { "loose-envify": "^1.1.0", "object-assign": "^4.1.1" @@ -8543,7 +8524,6 @@ "node_modules/react-dom": { "version": "17.0.2", "license": "MIT", - "peer": true, "dependencies": { "loose-envify": "^1.1.0", "object-assign": "^4.1.1", @@ -8608,7 +8588,6 @@ "node_modules/react-final-form": { "version": "6.5.9", "license": "MIT", - "peer": true, "dependencies": { "@babel/runtime": "^7.15.4" }, @@ -8624,7 +8603,6 @@ "node_modules/react-final-form-arrays": { "version": "3.1.4", "license": "MIT", - "peer": true, "dependencies": { "@babel/runtime": "^7.19.4" }, @@ -8711,7 +8689,6 @@ "node_modules/react-redux": { "version": "7.2.9", "license": "MIT", - "peer": true, "dependencies": { "@babel/runtime": "^7.15.4", "@types/react-redux": "^7.1.20", @@ -8743,7 +8720,6 @@ "node_modules/react-router": { "version": "5.3.4", "license": "MIT", - "peer": true, "dependencies": { "@babel/runtime": "^7.12.13", "history": "^4.9.0", @@ -8762,7 +8738,6 @@ "node_modules/react-router-dom": { "version": "5.3.4", "license": "MIT", - "peer": true, "dependencies": { "@babel/runtime": "^7.12.13", "history": "^4.9.0", @@ -8916,7 +8891,6 @@ "node_modules/redux": { "version": "4.2.1", "license": "MIT", - "peer": true, "dependencies": { "@babel/runtime": "^7.9.2" } @@ -8924,7 +8898,6 @@ "node_modules/redux-saga": { "version": "1.4.2", "license": "MIT", - "peer": true, "dependencies": { "@redux-saga/core": "^1.4.2" } @@ -9133,7 +9106,6 @@ "version": "4.55.2", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "@types/estree": "1.0.8" }, @@ -9925,10 +9897,11 @@ } }, "node_modules/tinyglobby/node_modules/picomatch": { - "version": "4.0.3", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -10116,7 +10089,6 @@ "version": "5.9.3", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -10319,7 +10291,6 @@ "version": "7.3.1", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -10435,10 +10406,11 @@ } }, "node_modules/vite/node_modules/picomatch": { - "version": "4.0.3", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -10450,7 +10422,6 @@ "version": "4.0.17", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@vitest/expect": "4.0.17", "@vitest/mocker": "4.0.17", @@ -10524,7 +10495,9 @@ } }, "node_modules/vitest/node_modules/picomatch": { - "version": "4.0.3", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", "engines": { @@ -10904,7 +10877,6 @@ "node_modules/workbox-build/node_modules/ajv": { "version": "8.18.0", "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -10978,7 +10950,6 @@ "node_modules/workbox-build/node_modules/rollup": { "version": "2.79.2", "license": "MIT", - "peer": true, "bin": { "rollup": "dist/bin/rollup" }, diff --git a/ui/src/album/AlbumGridView.jsx b/ui/src/album/AlbumGridView.jsx index e90e7a77b..c8a161571 100644 --- a/ui/src/album/AlbumGridView.jsx +++ b/ui/src/album/AlbumGridView.jsx @@ -18,6 +18,7 @@ import { PlayButton, ArtistLinkField, OverflowTooltip, + useImageUrl, } from '../common' import { COVER_ART_SIZE, DraggableTypes } from '../consts' import clsx from 'clsx' @@ -105,7 +106,7 @@ const useCoverStyles = makeStyles({ transition: 'opacity 0.3s ease-in-out', }, coverLoading: { - opacity: 0.5, + opacity: 0, }, }) @@ -125,8 +126,6 @@ const Cover = withContentRect('bounds')(({ // Force height to be the same as the width determined by the GridList // noinspection JSSuspiciousNameCombination const classes = useCoverStyles({ height: contentRect.bounds.width }) - const [imageLoading, setImageLoading] = React.useState(true) - const [imageError, setImageError] = React.useState(false) const [, dragAlbumRef] = useDrag( () => ({ type: DraggableTypes.ALBUM, @@ -136,32 +135,16 @@ const Cover = withContentRect('bounds')(({ [record], ) - // Reset image state when record changes - React.useEffect(() => { - setImageLoading(true) - setImageError(false) - }, [record.id]) - - const handleImageLoad = React.useCallback(() => { - setImageLoading(false) - setImageError(false) - }, []) - - const handleImageError = React.useCallback(() => { - setImageLoading(false) - setImageError(true) - }, []) + const url = subsonic.getCoverArtUrl(record, COVER_ART_SIZE, true) + const { imgUrl, loading: imageLoading } = useImageUrl(url) return (
{record.name}
diff --git a/ui/src/album/AlbumList.jsx b/ui/src/album/AlbumList.jsx index d00d97701..28a24981c 100644 --- a/ui/src/album/AlbumList.jsx +++ b/ui/src/album/AlbumList.jsx @@ -175,12 +175,12 @@ const AlbumListTitle = ({ albumListType }) => { return } -const AlbumListPagination = (props) => { +const AlbumListPagination = ({ albumListType, ...rest }) => { const { loading } = useListContext() - if (loading) { + if (loading && albumListType === 'random') { return null } - return <Pagination {...props} /> + return <Pagination {...rest} /> } const randomStartingSeed = Math.random().toString() @@ -243,7 +243,12 @@ const AlbumList = (props) => { actions={<AlbumListActions />} filters={<AlbumFilter />} perPage={perPage} - pagination={<AlbumListPagination rowsPerPageOptions={perPageOptions} />} + pagination={ + <AlbumListPagination + rowsPerPageOptions={perPageOptions} + albumListType={albumListType} + /> + } title={<AlbumListTitle albumListType={albumListType} />} > {albumView.grid ? ( diff --git a/ui/src/common/CoverArtAvatar.jsx b/ui/src/common/CoverArtAvatar.jsx index 5642f2504..bc6dd8a3c 100644 --- a/ui/src/common/CoverArtAvatar.jsx +++ b/ui/src/common/CoverArtAvatar.jsx @@ -4,12 +4,16 @@ import { makeStyles } from '@material-ui/core/styles' import clsx from 'clsx' import { COVER_ART_SIZE } from '../consts' import subsonic from '../subsonic' +import { useImageUrl } from './useImageUrl' const useStyles = makeStyles({ avatar: { width: '55px', height: '55px', }, + avatarEmpty: { + backgroundColor: 'transparent', + }, square: { borderRadius: '4px', }, @@ -22,15 +26,26 @@ export const CoverArtAvatar = ({ const classes = useStyles() const recordContext = useRecordContext() const record = recordProp || recordContext - if (!record) return null const square = variant !== 'circular' + const url = record + ? subsonic.getCoverArtUrl(record, COVER_ART_SIZE, square) + : null + const { imgUrl } = useImageUrl(url) + if (!record) return null return ( <Avatar - src={subsonic.getCoverArtUrl(record, COVER_ART_SIZE, square)} + src={imgUrl || undefined} variant={variant} - className={clsx(classes.avatar, square && classes.square)} + className={clsx( + classes.avatar, + square && classes.square, + !imgUrl && classes.avatarEmpty, + )} alt={record.name} - /> + > + {/* Empty child prevents default person icon while loading */} + {!imgUrl && <span />} + </Avatar> ) } diff --git a/ui/src/common/index.js b/ui/src/common/index.js index a7d6a43c4..362a0ced3 100644 --- a/ui/src/common/index.js +++ b/ui/src/common/index.js @@ -46,3 +46,4 @@ export * from './useSearchRefocus' export * from './ImageUploadOverlay' export * from './CoverArtAvatar' export * from './useImageLoadingState' +export * from './useImageUrl' diff --git a/ui/src/common/useImageUrl.js b/ui/src/common/useImageUrl.js new file mode 100644 index 000000000..9bcc70d74 --- /dev/null +++ b/ui/src/common/useImageUrl.js @@ -0,0 +1,144 @@ +import { useEffect, useState, useRef } from 'react' + +// Persists across component mount/unmount cycles so that +// React Admin refreshes (which remount list items) don't re-fetch images. +const cache = new Map() +const MAX_CACHE_SIZE = 300 + +// Limit concurrent fetches to leave browser connections free for API requests. +// Browsers allow ~6 connections per origin on HTTP/1.1; reserving 2 for API +// calls prevents image fetches from blocking pagination/data requests. +const MAX_CONCURRENT = 4 +let activeFetches = 0 +const pendingQueue = [] + +const processQueue = () => { + while (pendingQueue.length > 0 && activeFetches < MAX_CONCURRENT) { + const next = pendingQueue.shift() + next() + } +} + +// Evicts oldest unused entries (Map iterates in insertion order). +const evictIfNeeded = () => { + if (cache.size <= MAX_CACHE_SIZE) return + for (const [key, entry] of cache) { + if (cache.size <= MAX_CACHE_SIZE) break + if (entry.refCount === 0) { + if (entry.blobUrl) URL.revokeObjectURL(entry.blobUrl) + cache.delete(key) + } + } +} + +/** + * Loads an image via fetch() with AbortController so that in-flight requests + * are canceled on unmount (e.g., during pagination). Uses a module-level cache + * so remounting returns the cached blob URL instantly. + */ +export const useImageUrl = (url) => { + const cached = url ? cache.get(url) : null + const [imgUrl, setImgUrl] = useState(cached?.blobUrl || null) + const [loading, setLoading] = useState(!!url && !cached) + const [error, setError] = useState(cached?.error || false) + const abortedRef = useRef(false) + + useEffect(() => { + abortedRef.current = false + + if (!url) { + setImgUrl(null) + setLoading(false) + setError(false) + return + } + + // Re-check: another component's effect may have populated the cache + // between this component's render and effect execution. + const entry = cache.get(url) + if (entry) { + entry.refCount++ + setImgUrl(entry.blobUrl) + setLoading(false) + setError(entry.error || false) + return () => { + entry.refCount-- + } + } + + const controller = new AbortController() + let queued = true + setImgUrl(null) + setLoading(true) + setError(false) + + const doFetch = () => { + queued = false + activeFetches++ + fetch(url, { signal: controller.signal }) + .then((res) => { + if (!res.ok) { + throw new Error(`HTTP ${res.status}`) + } + return res.blob() + }) + .then((blob) => { + activeFetches-- + processQueue() + // Guard against late resolution after abort + if (abortedRef.current) { + return + } + const objectUrl = URL.createObjectURL(blob) + // Handle concurrent fetches: if another component already cached + // this URL, use its entry and discard our blob. + const existing = cache.get(url) + if (existing && existing.blobUrl) { + existing.refCount++ + URL.revokeObjectURL(objectUrl) + setImgUrl(existing.blobUrl) + } else { + cache.set(url, { blobUrl: objectUrl, refCount: 1 }) + evictIfNeeded() + setImgUrl(objectUrl) + } + setLoading(false) + }) + .catch((err) => { + activeFetches-- + processQueue() + if (err.name === 'AbortError') { + return // Expected on unmount or URL change + } + // Cache the error so repeated mounts don't re-fetch broken URLs + cache.set(url, { blobUrl: null, error: true, refCount: 0 }) + setError(true) + setLoading(false) + }) + } + + if (activeFetches < MAX_CONCURRENT) { + queued = false + doFetch() + } else { + pendingQueue.push(doFetch) + } + + return () => { + abortedRef.current = true + if (queued) { + // Remove from queue if not yet started + const idx = pendingQueue.indexOf(doFetch) + if (idx !== -1) pendingQueue.splice(idx, 1) + } else { + controller.abort() + } + const entry = cache.get(url) + if (entry) { + entry.refCount-- + } + } + }, [url]) + + return { imgUrl, loading, error } +} diff --git a/ui/src/common/useImageUrl.test.js b/ui/src/common/useImageUrl.test.js new file mode 100644 index 000000000..976317105 --- /dev/null +++ b/ui/src/common/useImageUrl.test.js @@ -0,0 +1,234 @@ +import { renderHook, act } from '@testing-library/react-hooks' +import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest' + +// Helper to flush all pending promises +const flushPromises = () => new Promise((resolve) => setTimeout(resolve, 0)) + +// We need a fresh module for each test to reset the module-level cache +let useImageUrl + +describe('useImageUrl', () => { + let abortSpy + let OriginalAbortController + let originalCreateObjectURL + let originalRevokeObjectURL + let originalFetch + + beforeEach(async () => { + // Reset module to clear the cache + vi.resetModules() + const mod = await import('./useImageUrl') + useImageUrl = mod.useImageUrl + + abortSpy = vi.fn() + OriginalAbortController = global.AbortController + originalCreateObjectURL = global.URL.createObjectURL + originalRevokeObjectURL = global.URL.revokeObjectURL + originalFetch = global.fetch + + global.AbortController = function () { + this.signal = 'mock-signal' + this.abort = abortSpy + } + global.URL.createObjectURL = vi.fn(() => 'blob:mock-url') + global.URL.revokeObjectURL = vi.fn() + }) + + afterEach(() => { + global.AbortController = OriginalAbortController + global.URL.createObjectURL = originalCreateObjectURL + global.URL.revokeObjectURL = originalRevokeObjectURL + global.fetch = originalFetch + vi.restoreAllMocks() + }) + + it('should return null values when url is null', () => { + const { result } = renderHook(() => useImageUrl(null)) + + expect(result.current.loading).toBe(false) + expect(result.current.imgUrl).toBeNull() + expect(result.current.error).toBe(false) + }) + + it('should return loading state initially', () => { + global.fetch = vi.fn(() => new Promise(() => {})) + const { result } = renderHook(() => + useImageUrl('http://example.com/img.jpg'), + ) + + expect(result.current.loading).toBe(true) + expect(result.current.imgUrl).toBeNull() + expect(result.current.error).toBe(false) + }) + + it('should fetch image and return blob URL on success', async () => { + const mockBlob = new Blob(['image-data'], { type: 'image/png' }) + global.fetch = vi.fn(() => + Promise.resolve({ + ok: true, + blob: () => Promise.resolve(mockBlob), + }), + ) + + const { result } = renderHook(() => + useImageUrl('http://example.com/img.jpg'), + ) + + await act(async () => { + await flushPromises() + }) + + expect(result.current.loading).toBe(false) + expect(result.current.imgUrl).toBe('blob:mock-url') + expect(result.current.error).toBe(false) + expect(global.fetch).toHaveBeenCalledWith('http://example.com/img.jpg', { + signal: 'mock-signal', + }) + }) + + it('should set error on HTTP failure', async () => { + global.fetch = vi.fn(() => Promise.resolve({ ok: false, status: 404 })) + + const { result } = renderHook(() => + useImageUrl('http://example.com/missing.jpg'), + ) + + await act(async () => { + await flushPromises() + }) + + expect(result.current.loading).toBe(false) + expect(result.current.imgUrl).toBeNull() + expect(result.current.error).toBe(true) + }) + + it('should abort fetch on unmount', async () => { + global.fetch = vi.fn(() => new Promise(() => {})) + + const { unmount } = renderHook(() => + useImageUrl('http://example.com/img.jpg'), + ) + + await act(async () => { + await flushPromises() + }) + + unmount() + + expect(abortSpy).toHaveBeenCalled() + }) + + it('should abort previous fetch when URL changes', async () => { + const abortSpies = [] + global.AbortController = function () { + const spy = vi.fn() + abortSpies.push(spy) + this.signal = `signal-${abortSpies.length}` + this.abort = spy + } + + const mockBlob = new Blob(['data'], { type: 'image/png' }) + global.fetch = vi.fn(() => + Promise.resolve({ + ok: true, + blob: () => Promise.resolve(mockBlob), + }), + ) + + const { rerender } = renderHook(({ url }) => useImageUrl(url), { + initialProps: { url: 'http://example.com/img1.jpg' }, + }) + + await act(async () => { + await flushPromises() + }) + + // Change URL - should abort the first controller + rerender({ url: 'http://example.com/img2.jpg' }) + + expect(abortSpies[0]).toHaveBeenCalled() + }) + + it('should not set error on AbortError', async () => { + const abortError = new DOMException('Aborted', 'AbortError') + global.fetch = vi.fn(() => Promise.reject(abortError)) + + const { result } = renderHook(() => + useImageUrl('http://example.com/img.jpg'), + ) + + await act(async () => { + await flushPromises() + }) + + expect(result.current.error).toBe(false) + }) + + it('should use cached blob URL on remount without re-fetching', async () => { + const mockBlob = new Blob(['data'], { type: 'image/png' }) + global.fetch = vi.fn(() => + Promise.resolve({ + ok: true, + blob: () => Promise.resolve(mockBlob), + }), + ) + + // First mount — fetches and caches + const { unmount } = renderHook(() => + useImageUrl('http://example.com/img.jpg'), + ) + + await act(async () => { + await flushPromises() + }) + + expect(global.fetch).toHaveBeenCalledTimes(1) + + // Unmount (simulates React Admin refresh) + unmount() + + // Remount with same URL — should use cache + const { result: result2 } = renderHook(() => + useImageUrl('http://example.com/img.jpg'), + ) + + await act(async () => { + await flushPromises() + }) + + // Should NOT have fetched again + expect(global.fetch).toHaveBeenCalledTimes(1) + expect(result2.current.imgUrl).toBe('blob:mock-url') + expect(result2.current.loading).toBe(false) + }) + + it('should cache errors and not re-fetch broken URLs', async () => { + global.fetch = vi.fn(() => Promise.resolve({ ok: false, status: 404 })) + + // First mount — fetch fails and error is cached + const { unmount } = renderHook(() => + useImageUrl('http://example.com/broken.jpg'), + ) + + await act(async () => { + await flushPromises() + }) + + expect(global.fetch).toHaveBeenCalledTimes(1) + unmount() + + // Remount with same URL — should use cached error, not re-fetch + const { result: result2 } = renderHook(() => + useImageUrl('http://example.com/broken.jpg'), + ) + + await act(async () => { + await flushPromises() + }) + + expect(global.fetch).toHaveBeenCalledTimes(1) + expect(result2.current.error).toBe(true) + expect(result2.current.imgUrl).toBeNull() + expect(result2.current.loading).toBe(false) + }) +}) diff --git a/ui/src/radio/RadioList.jsx b/ui/src/radio/RadioList.jsx index 582fcaffc..945bac519 100644 --- a/ui/src/radio/RadioList.jsx +++ b/ui/src/radio/RadioList.jsx @@ -14,8 +14,12 @@ import { UrlField, useTranslate, } from 'react-admin' -import { List } from '../common' -import { ToggleFieldsMenu, useSelectedFields } from '../common' +import { + List, + useImageUrl, + ToggleFieldsMenu, + useSelectedFields, +} from '../common' import subsonic from '../subsonic' import { StreamField } from './StreamField' import { setTrack } from '../actions' @@ -78,10 +82,12 @@ const RadioListActions = ({ const avatarStyle = { width: 40, height: 40 } const CoverArtField = ({ record }) => { - if (!record) return null - const src = record.uploadedImage + const directUrl = record?.uploadedImage ? subsonic.getCoverArtUrl(record, 40, true) - : RADIO_PLACEHOLDER_IMAGE + : null + const { imgUrl } = useImageUrl(directUrl) + if (!record) return null + const src = imgUrl || RADIO_PLACEHOLDER_IMAGE return ( <Avatar src={src} variant="rounded" style={avatarStyle} alt={record.name} /> )