feat(ui): show the artwork resolution trace in the album info dialog

This commit is contained in:
Deluan 2026-09-03 23:33:14 -04:00
parent d17ef212ab
commit e7f7e7bde9
5 changed files with 294 additions and 0 deletions

View File

@ -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) => {
)
})}
<ParticipantsInfo record={record} classes={classes} />
<ArtworkInfo resource="album" id={record.id} />
</TableBody>
</Table>
</TableContainer>

View File

@ -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 (
<Chip
size="small"
label={outcome}
className={classes.chip}
style={{ backgroundColor: OUTCOME_COLORS[outcome] || '#9e9e9e' }}
/>
)
}
OutcomeChip.propTypes = {
outcome: PropTypes.string.isRequired,
}
const StepTable = ({ title, steps }) => {
if (!steps?.length) return null
return (
<>
<TableRow>
<TableCell colSpan={2}>
<strong>{title}</strong>
</TableCell>
</TableRow>
{steps.map((s, i) => (
<TableRow key={`${s.candidate}-${i}`}>
<TableCell>{s.candidate}</TableCell>
<TableCell>
<OutcomeChip outcome={s.outcome} /> {s.detail}
</TableCell>
</TableRow>
))}
</>
)
}
StepTable.propTypes = {
title: PropTypes.string.isRequired,
steps: PropTypes.array,
}
const Row = ({ label, children }) => (
<TableRow>
<TableCell component="th" scope="row">
{label}:
</TableCell>
<TableCell align="left">{children}</TableCell>
</TableRow>
)
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 (
<>
<TableRow>
<TableCell colSpan={2}>
<strong>{translate('artwork.title')}</strong>
</TableCell>
</TableRow>
<Row label={translate('artwork.result')}>{report.result}</Row>
{recorded ? (
<>
<Row label={translate('artwork.source')}>
{report.stored?.source}
</Row>
<Row label={translate('artwork.attemptedAt')}>
{report.stored?.attemptedAt}
</Row>
</>
) : (
<Row label={translate('artwork.source')}>
{translate('artwork.notRecorded')}
</Row>
)}
<TableRow>
<TableCell colSpan={2}>
<Link
component="button"
className={classes.toggle}
onClick={() => setExpanded(!expanded)}
>
{translate(expanded ? 'artwork.hideDetails' : 'artwork.showDetails')}
</Link>
</TableCell>
</TableRow>
{expanded && (
<>
<StepTable title={translate('artwork.chain')} steps={report.steps} />
<StepTable
title={translate('artwork.lastAttemptFailed')}
steps={report.lastAttemptFailed}
/>
<StepTable
title={translate('artwork.gaveUpAfter')}
steps={report.gaveUpAfter}
/>
{report.queued && (
<>
<Row label={translate('artwork.priority')}>
{report.queued.priority}
</Row>
<Row label={translate('artwork.attempts')}>
{report.queued.attempts}
</Row>
<Row label={translate('artwork.retryAt')}>
{report.queued.retryAt}
</Row>
</>
)}
{report.config && (
<Row label={report.config.setting}>{report.config.value}</Row>
)}
{report.agents && (
<Row label={translate('artwork.agents')}>{report.agents}</Row>
)}
</>
)}
</>
)
}
ArtworkInfo.propTypes = {
resource: PropTypes.oneOf(['album', 'artist']).isRequired,
id: PropTypes.string.isRequired,
}

View File

@ -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(
<table>
<tbody>{ui}</tbody>
</table>,
)
describe('<ArtworkInfo />', () => {
beforeEach(() => {
mockPermissions.value = 'admin'
explainArtwork.mockReset()
explainArtwork.mockResolvedValue({ data: report })
})
it('renders nothing for a non-admin', () => {
mockPermissions.value = 'regular'
const { container } = renderInTable(
<ArtworkInfo resource="artist" id="ar-1" />,
)
expect(container.querySelector('tbody')).toBeEmptyDOMElement()
expect(explainArtwork).not.toHaveBeenCalled()
})
it('shows the summary for an admin', async () => {
renderInTable(<ArtworkInfo resource="artist" id="ar-1" />)
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(<ArtworkInfo resource="artist" id="ar-1" />)
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(<ArtworkInfo resource="artist" id="ar-1" />)
expect(await screen.findByText('artwork.notRecorded')).toBeInTheDocument()
})
it('renders nothing when the fetch fails', async () => {
explainArtwork.mockRejectedValue(new Error('boom'))
const { container } = renderInTable(
<ArtworkInfo resource="artist" id="ar-1" />,
)
await waitFor(() => expect(explainArtwork).toHaveBeenCalled())
expect(container.querySelector('tbody')).toBeEmptyDOMElement()
})
})

View File

@ -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'

View File

@ -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",