mirror of
https://github.com/navidrome/navidrome.git
synced 2026-10-18 07:30:29 +00:00
feat(ui): show the artwork resolution trace in the album info dialog
This commit is contained in:
parent
d17ef212ab
commit
e7f7e7bde9
@ -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>
|
||||
|
||||
177
ui/src/common/ArtworkInfo.jsx
Normal file
177
ui/src/common/ArtworkInfo.jsx
Normal 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,
|
||||
}
|
||||
93
ui/src/common/ArtworkInfo.test.jsx
Normal file
93
ui/src/common/ArtworkInfo.test.jsx
Normal 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()
|
||||
})
|
||||
})
|
||||
@ -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'
|
||||
|
||||
@ -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",
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user