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( + + {ui} +
, + ) + +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",