feat(artwork): add per-disc cover art support (#5182)
* feat(artwork): add KindDiscArtwork and ParseDiscArtworkID Add new disc artwork kind with 'dc' prefix for per-disc cover art support. The composite ID format is albumID:discNumber, parsed by the new ParseDiscArtworkID helper. * feat(conf): add DiscArtPriority configuration option Default: 'disc*.*, cd*.*, embedded'. Controls how per-disc cover art is resolved, following the same pattern as CoverArtPriority and ArtistArtPriority. * feat(artwork): implement extractDiscNumber helper Extracts disc number from filenames based on glob patterns by parsing leading digits from the wildcard-matched portion. Used for matching disc-specific artwork files like disc1.jpg. * feat(artwork): implement fromDiscExternalFile source function Disc-aware variant of fromExternalFile that filters image files by disc number (extracted from filename) or folder association (for multi-folder albums). * feat(artwork): implement discArtworkReader Resolves disc artwork using DiscArtPriority config patterns. Supports glob patterns with disc number extraction, embedded images from first track, and falls back to album cover art. Handles both multi-folder and single-folder multi-disc albums. * feat(artwork): register disc artwork reader in dispatcher Add KindDiscArtwork case to getArtworkReader switch, routing disc artwork requests to the new discArtworkReader. * feat(subsonic): add CoverArt field to DiscTitle response Implements OpenSubsonic PR #220: optional cover art ID in DiscTitle responses for per-disc artwork support. * feat(subsonic): populate CoverArt in DiscTitle responses Each DiscTitle now includes a disc artwork ID (dc-albumID:discNum) that clients can use with getCoverArt to retrieve per-disc artwork. * style: fix file permission in test to satisfy gosec * feat(ui): add disc cover art display and lightbox functionality Signed-off-by: Deluan <deluan@navidrome.org> * refactor: simplify disc artwork code - Add DiscArtworkID constructor to encapsulate the "albumID:discNumber" format in one place - Convert fromDiscExternalFile to a method on discArtworkReader, reducing parameter count from 6 to 2 - Remove unused rootFolder field from discArtworkReader * style: fix prettier formatting in subsonic index * style(ui): move cursor style to makeStyles in SongDatagrid * feat(artwork): add discsubtitle option to DiscArtPriority Allow matching disc cover art by the disc's subtitle/name. When the "discsubtitle" keyword is in the priority list, image files whose stem matches the disc subtitle (case-insensitive) are used. This is useful for box sets with named discs (e.g., "The Blue Disc.jpg"). * feat(configuration): update discartpriority to include cover art options Signed-off-by: Deluan <deluan@navidrome.org> --------- Signed-off-by: Deluan <deluan@navidrome.org>
This commit is contained in:
@@ -1,4 +1,10 @@
|
||||
import React, { isValidElement, useMemo, useCallback, forwardRef } from 'react'
|
||||
import React, {
|
||||
isValidElement,
|
||||
useMemo,
|
||||
useCallback,
|
||||
useState,
|
||||
forwardRef,
|
||||
} from 'react'
|
||||
import { useDispatch } from 'react-redux'
|
||||
import {
|
||||
Datagrid,
|
||||
@@ -17,7 +23,10 @@ import { makeStyles } from '@material-ui/core/styles'
|
||||
import AlbumIcon from '@material-ui/icons/Album'
|
||||
import clsx from 'clsx'
|
||||
import { useDrag } from 'react-dnd'
|
||||
import Lightbox from 'react-image-lightbox'
|
||||
import 'react-image-lightbox/style.css'
|
||||
import { playTracks } from '../actions'
|
||||
import subsonic from '../subsonic'
|
||||
import { AlbumContextMenu } from '../common'
|
||||
import { DraggableTypes } from '../consts'
|
||||
import { formatFullDate } from '../utils'
|
||||
@@ -28,10 +37,20 @@ const useStyles = makeStyles({
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
verticalAlign: 'middle',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
},
|
||||
discIcon: {
|
||||
verticalAlign: 'text-top',
|
||||
marginRight: '4px',
|
||||
marginRight: '14px',
|
||||
},
|
||||
discCoverArt: {
|
||||
width: '48px',
|
||||
height: '48px',
|
||||
marginRight: '14px',
|
||||
objectFit: 'cover',
|
||||
borderRadius: '4px',
|
||||
flexShrink: 0,
|
||||
cursor: 'pointer',
|
||||
},
|
||||
row: {
|
||||
cursor: 'pointer',
|
||||
@@ -61,19 +80,45 @@ const useStyles = makeStyles({
|
||||
|
||||
const DiscSubtitleRow = forwardRef(
|
||||
({ record, onClick, colSpan, contextAlwaysVisible }, ref) => {
|
||||
const translate = useTranslate()
|
||||
const isDesktop = useMediaQuery((theme) => theme.breakpoints.up('md'))
|
||||
const classes = useStyles({ isDesktop })
|
||||
const [imageError, setImageError] = useState(false)
|
||||
const [isLightboxOpen, setLightboxOpen] = useState(false)
|
||||
const handlePlaySubset = (discNumber) => () => {
|
||||
onClick(discNumber)
|
||||
}
|
||||
|
||||
let subtitle = []
|
||||
if (record.discNumber > 0) {
|
||||
subtitle.push(record.discNumber)
|
||||
}
|
||||
if (record.discSubtitle) {
|
||||
subtitle.push(record.discSubtitle)
|
||||
}
|
||||
const coverArtUrl = subsonic.getDiscCoverArtUrl(
|
||||
record.albumId,
|
||||
record.discNumber,
|
||||
record.updatedAt,
|
||||
96,
|
||||
)
|
||||
|
||||
const fullImageUrl = subsonic.getDiscCoverArtUrl(
|
||||
record.albumId,
|
||||
record.discNumber,
|
||||
record.updatedAt,
|
||||
)
|
||||
|
||||
const handleOpenLightbox = useCallback(
|
||||
(e) => {
|
||||
if (!imageError) {
|
||||
e.stopPropagation()
|
||||
setLightboxOpen(true)
|
||||
}
|
||||
},
|
||||
[imageError],
|
||||
)
|
||||
|
||||
const handleCloseLightbox = useCallback(() => setLightboxOpen(false), [])
|
||||
|
||||
const subtitle = record.discSubtitle
|
||||
? record.discSubtitle
|
||||
: translate('resources.song.fields.disc', {
|
||||
discNumber: record.discNumber,
|
||||
})
|
||||
|
||||
return (
|
||||
<TableRow
|
||||
@@ -84,9 +129,28 @@ const DiscSubtitleRow = forwardRef(
|
||||
>
|
||||
<TableCell colSpan={colSpan}>
|
||||
<Typography variant="h6" className={classes.subtitle}>
|
||||
<AlbumIcon className={classes.discIcon} fontSize={'small'} />
|
||||
{subtitle.join(': ')}
|
||||
{!imageError ? (
|
||||
<img
|
||||
src={coverArtUrl}
|
||||
className={classes.discCoverArt}
|
||||
alt=""
|
||||
onClick={handleOpenLightbox}
|
||||
onError={() => setImageError(true)}
|
||||
/>
|
||||
) : (
|
||||
<AlbumIcon className={classes.discIcon} fontSize={'small'} />
|
||||
)}
|
||||
{subtitle}
|
||||
</Typography>
|
||||
{isLightboxOpen && !imageError && (
|
||||
<Lightbox
|
||||
imagePadding={50}
|
||||
animationDuration={200}
|
||||
imageTitle={record.album + ' - ' + subtitle}
|
||||
mainSrc={fullImageUrl}
|
||||
onCloseRequest={handleCloseLightbox}
|
||||
/>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<AlbumContextMenu
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
"bitDepth": "Bit depth",
|
||||
"sampleRate": "Sample rate",
|
||||
"channels": "Channels",
|
||||
"disc": "Disc %{discNumber}",
|
||||
"discSubtitle": "Disc Subtitle",
|
||||
"starred": "Favourite",
|
||||
"comment": "Comment",
|
||||
|
||||
@@ -91,6 +91,16 @@ const getCoverArtUrl = (record, size, square) => {
|
||||
}
|
||||
}
|
||||
|
||||
const getDiscCoverArtUrl = (albumId, discNumber, updatedAt, size) => {
|
||||
const options = {
|
||||
...(updatedAt && { _: updatedAt }),
|
||||
...(size && { size }),
|
||||
}
|
||||
return baseUrl(
|
||||
url('getCoverArt', 'dc-' + albumId + ':' + discNumber, options),
|
||||
)
|
||||
}
|
||||
|
||||
const getArtistInfo = (id) => {
|
||||
return httpClient(url('getArtistInfo', id))
|
||||
}
|
||||
@@ -129,6 +139,7 @@ export default {
|
||||
getScanStatus,
|
||||
getNowPlaying,
|
||||
getCoverArtUrl,
|
||||
getDiscCoverArtUrl,
|
||||
getAvatarUrl,
|
||||
streamUrl,
|
||||
getAlbumInfo,
|
||||
|
||||
@@ -105,6 +105,56 @@ describe('getCoverArtUrl', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('getDiscCoverArtUrl', () => {
|
||||
beforeEach(() => {
|
||||
const localStorageMock = {
|
||||
getItem: vi.fn((key) => {
|
||||
const values = {
|
||||
username: 'testuser',
|
||||
'subsonic-token': 'testtoken',
|
||||
'subsonic-salt': 'testsalt',
|
||||
}
|
||||
return values[key] || null
|
||||
}),
|
||||
}
|
||||
Object.defineProperty(window, 'localStorage', { value: localStorageMock })
|
||||
})
|
||||
|
||||
it('should construct URL with dc-albumId:discNumber format, size, and cache param', () => {
|
||||
const url = subsonic.getDiscCoverArtUrl(
|
||||
'album-123',
|
||||
2,
|
||||
'2023-01-01T00:00:00Z',
|
||||
48,
|
||||
)
|
||||
|
||||
expect(url).toContain('getCoverArt')
|
||||
expect(url).toContain('id=dc-album-123%3A2')
|
||||
expect(url).toContain('size=48')
|
||||
expect(url).toContain('_=2023-01-01T00%3A00%3A00Z')
|
||||
})
|
||||
|
||||
it('should handle missing updatedAt', () => {
|
||||
const url = subsonic.getDiscCoverArtUrl('album-123', 1, undefined, 48)
|
||||
|
||||
expect(url).toContain('id=dc-album-123%3A1')
|
||||
expect(url).toContain('size=48')
|
||||
expect(url).not.toContain('_=')
|
||||
})
|
||||
|
||||
it('should handle missing size', () => {
|
||||
const url = subsonic.getDiscCoverArtUrl(
|
||||
'album-123',
|
||||
1,
|
||||
'2023-01-01T00:00:00Z',
|
||||
)
|
||||
|
||||
expect(url).toContain('id=dc-album-123%3A1')
|
||||
expect(url).toContain('_=2023-01-01T00%3A00%3A00Z')
|
||||
expect(url).not.toContain('size=')
|
||||
})
|
||||
})
|
||||
|
||||
describe('getAvatarUrl', () => {
|
||||
beforeEach(() => {
|
||||
// Mock localStorage values required by subsonic
|
||||
|
||||
Reference in New Issue
Block a user