diff --git a/ui/src/album/AlbumInfo.jsx b/ui/src/album/AlbumInfo.jsx
index 075841e43..c7a9eac3f 100644
--- a/ui/src/album/AlbumInfo.jsx
+++ b/ui/src/album/AlbumInfo.jsx
@@ -18,6 +18,7 @@ import {
import { makeStyles } from '@material-ui/core/styles'
import {
ArtistLinkField,
+ ArtworkInfo,
MultiLineTextField,
ParticipantsInfo,
RangeField,
@@ -139,6 +140,7 @@ const AlbumInfo = (props) => {
)
})}
+
diff --git a/ui/src/common/ArtworkInfo.jsx b/ui/src/common/ArtworkInfo.jsx
new file mode 100644
index 000000000..42eb0c699
--- /dev/null
+++ b/ui/src/common/ArtworkInfo.jsx
@@ -0,0 +1,177 @@
+import React, { useEffect, useState } from 'react'
+import PropTypes from 'prop-types'
+import {
+ Chip,
+ Link,
+ TableCell,
+ TableRow,
+} from '@material-ui/core'
+import { makeStyles } from '@material-ui/core/styles'
+import { useDataProvider, usePermissions, useTranslate } from 'react-admin'
+
+const OUTCOME_COLORS = {
+ hit: '#4caf50',
+ miss: '#9e9e9e',
+ skipped: '#9e9e9e',
+ error: '#f44336',
+ unreadable: '#f44336',
+}
+
+const useStyles = makeStyles({
+ chip: { color: '#fff', height: 20 },
+ toggle: { cursor: 'pointer' },
+})
+
+const OutcomeChip = ({ outcome }) => {
+ const classes = useStyles()
+ return (
+
+ )
+}
+
+OutcomeChip.propTypes = {
+ outcome: PropTypes.string.isRequired,
+}
+
+const StepTable = ({ title, steps }) => {
+ if (!steps?.length) return null
+ return (
+ <>
+
+
+ {title}
+
+
+ {steps.map((s, i) => (
+
+ {s.candidate}
+
+ {s.detail}
+
+
+ ))}
+ >
+ )
+}
+
+StepTable.propTypes = {
+ title: PropTypes.string.isRequired,
+ steps: PropTypes.array,
+}
+
+const Row = ({ label, children }) => (
+
+
+ {label}:
+
+ {children}
+
+)
+
+Row.propTypes = {
+ label: PropTypes.node.isRequired,
+ children: PropTypes.node,
+}
+
+export const ArtworkInfo = ({ resource, id }) => {
+ const classes = useStyles()
+ const translate = useTranslate()
+ const dataProvider = useDataProvider()
+ const { permissions } = usePermissions()
+ const [report, setReport] = useState(null)
+ const [expanded, setExpanded] = useState(false)
+ const isAdmin = permissions === 'admin'
+
+ useEffect(() => {
+ if (!isAdmin) return undefined
+ let live = true
+ dataProvider
+ .explainArtwork(resource, id)
+ .then(({ data }) => live && setReport(data))
+ .catch(() => live && setReport(null))
+ return () => {
+ live = false
+ }
+ }, [dataProvider, resource, id, isAdmin])
+
+ if (!isAdmin || !report) return null
+
+ const recorded = report.chainOrigin !== 'not recorded'
+ return (
+ <>
+
+
+ {translate('artwork.title')}
+
+
+ {report.result}
+ {recorded ? (
+ <>
+
+ {report.stored?.source}
+
+
+ {report.stored?.attemptedAt}
+
+ >
+ ) : (
+
+ {translate('artwork.notRecorded')}
+
+ )}
+
+
+ setExpanded(!expanded)}
+ >
+ {translate(expanded ? 'artwork.hideDetails' : 'artwork.showDetails')}
+
+
+
+ {expanded && (
+ <>
+
+
+
+ {report.queued && (
+ <>
+
+ {report.queued.priority}
+
+
+ {report.queued.attempts}
+
+
+ {report.queued.retryAt}
+
+ >
+ )}
+ {report.config && (
+ {report.config.value}
+ )}
+ {report.agents && (
+ {report.agents}
+ )}
+ >
+ )}
+ >
+ )
+}
+
+ArtworkInfo.propTypes = {
+ resource: PropTypes.oneOf(['album', 'artist']).isRequired,
+ id: PropTypes.string.isRequired,
+}
diff --git a/ui/src/common/ArtworkInfo.test.jsx b/ui/src/common/ArtworkInfo.test.jsx
new file mode 100644
index 000000000..2a793911a
--- /dev/null
+++ b/ui/src/common/ArtworkInfo.test.jsx
@@ -0,0 +1,93 @@
+import React from 'react'
+import { render, screen, waitFor } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+import { describe, it, expect, vi, beforeEach } from 'vitest'
+import { ArtworkInfo } from './ArtworkInfo'
+
+const explainArtwork = vi.fn()
+const { mockPermissions } = vi.hoisted(() => ({
+ mockPermissions: { value: 'admin' },
+}))
+
+vi.mock('react-admin', async (importOriginal) => {
+ const actual = await importOriginal()
+ return {
+ ...actual,
+ useDataProvider: () => ({ explainArtwork }),
+ usePermissions: () => ({ permissions: mockPermissions.value }),
+ useTranslate: () => (key) => key,
+ }
+})
+
+const report = {
+ result: 'resolved from external:deezer',
+ chainOrigin: 'recorded 2026-09-01T10:00:00Z',
+ stored: { source: 'external:deezer', attemptedAt: '2026-09-01T10:00:00Z' },
+ steps: [
+ { candidate: 'cover.*', outcome: 'miss' },
+ { candidate: 'external:deezer', outcome: 'hit', detail: 'https://cdn/x.jpg' },
+ ],
+ config: { setting: 'ArtistArtPriority', value: 'external' },
+}
+
+// ArtworkInfo renders bare TableRows to slot into the caller's TableBody, so it needs a
+// table/tbody ancestor or React logs DOM-nesting warnings.
+const renderInTable = (ui) =>
+ render(
+
,
+ )
+
+describe('', () => {
+ beforeEach(() => {
+ mockPermissions.value = 'admin'
+ explainArtwork.mockReset()
+ explainArtwork.mockResolvedValue({ data: report })
+ })
+
+ it('renders nothing for a non-admin', () => {
+ mockPermissions.value = 'regular'
+ const { container } = renderInTable(
+ ,
+ )
+ expect(container.querySelector('tbody')).toBeEmptyDOMElement()
+ expect(explainArtwork).not.toHaveBeenCalled()
+ })
+
+ it('shows the summary for an admin', async () => {
+ renderInTable()
+ expect(
+ await screen.findByText('resolved from external:deezer'),
+ ).toBeInTheDocument()
+ expect(screen.queryByText('external:deezer')).not.toBeNull()
+ expect(screen.queryByText('cover.*')).toBeNull()
+ })
+
+ it('reveals the step table when details are expanded', async () => {
+ renderInTable()
+ await screen.findByText('resolved from external:deezer')
+ await userEvent.click(screen.getByText('artwork.showDetails'))
+ expect(screen.getByText('cover.*')).toBeInTheDocument()
+ expect(
+ screen.getByText('ArtistArtPriority', { exact: false }),
+ ).toBeInTheDocument()
+ })
+
+ it('shows the not-recorded state', async () => {
+ explainArtwork.mockResolvedValue({
+ data: { result: 'not resolved', chainOrigin: 'not recorded', steps: [] },
+ })
+ renderInTable()
+ expect(await screen.findByText('artwork.notRecorded')).toBeInTheDocument()
+ })
+
+ it('renders nothing when the fetch fails', async () => {
+ explainArtwork.mockRejectedValue(new Error('boom'))
+ const { container } = renderInTable(
+ ,
+ )
+ await waitFor(() => expect(explainArtwork).toHaveBeenCalled())
+ expect(container.querySelector('tbody')).toBeEmptyDOMElement()
+ })
+})
diff --git a/ui/src/common/index.js b/ui/src/common/index.js
index 0177df326..f038f7eb4 100644
--- a/ui/src/common/index.js
+++ b/ui/src/common/index.js
@@ -1,6 +1,7 @@
export * from './AddToPlaylistButton'
export * from './artist'
export * from './ArtistLinkField'
+export * from './ArtworkInfo'
export * from './BatchPlayButton'
export * from './BitrateField'
export * from './CollapsibleComment'
diff --git a/ui/src/i18n/en.json b/ui/src/i18n/en.json
index de96d47c0..e733dfeba 100644
--- a/ui/src/i18n/en.json
+++ b/ui/src/i18n/en.json
@@ -646,6 +646,27 @@
"sharedPlaylists": "Shared Playlists",
"about": "About"
},
+ "artwork": {
+ "title": "Artwork",
+ "chain": "Chain",
+ "result": "Result",
+ "source": "Source",
+ "attemptedAt": "Attempted at",
+ "showDetails": "Show details",
+ "hideDetails": "Hide details",
+ "candidate": "Candidate",
+ "outcome": "Outcome",
+ "detail": "Detail",
+ "queue": "Queue",
+ "priority": "Priority",
+ "attempts": "Attempts",
+ "retryAt": "Retry at",
+ "lastAttemptFailed": "Last attempt failed",
+ "gaveUpAfter": "Gave up after",
+ "config": "Config",
+ "agents": "Agents",
+ "notRecorded": "No resolution recorded yet"
+ },
"player": {
"playListsText": "Play Queue",
"openText": "Open",