feat(playlist): add custom playlist cover art upload (#5110)
* feat(playlist): add custom playlist cover art upload - #406 Allow users to upload, view, and remove custom cover images for playlists. Custom images take priority over the auto-generated tiled artwork. Backend: - Add `image_path` column to playlist table (migration with proper rollback) - Add `SetImage`/`RemoveImage` methods to playlist service - Add `POST/DELETE /api/playlist/{id}/image` endpoints - Prioritize custom image in artwork reader pipeline - Clean up image files on playlist deletion - Use glob-based cleanup to prevent orphaned files across format changes - Reject uploads with undetermined image type (400) Frontend: - Hover overlay on playlist cover with upload (camera) and remove (trash) buttons - Lightbox for full-size cover art viewing - Cover art thumbnails in the playlist list view - Loading/error states and i18n strings Closes #406 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> Signed-off-by: adrbn <128328324+adrbn@users.noreply.github.com> * refactor: rename playlist image path migration file Signed-off-by: Deluan <deluan@navidrome.org> * fix(playlist): address review feedback for cover art upload - #406 - Use httpClient instead of raw fetch for image upload/remove - Revert glob cleanup to simple imagePath check - Add log.Error before all error HTTP responses - Add backend tests for SetImage and RemoveImage Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> Signed-off-by: adrbn <128328324+adrbn@users.noreply.github.com> * refactor(playlist): use Playlist.ArtworkPath() for image storage Migrate all playlist image path handling to use the new Playlist.ArtworkPath() method as the single source of truth. The DB now stores only the filename (e.g. "pls-1.jpg") instead of a relative path, and images are stored under {DataFolder}/artwork/playlist/ instead of {DataFolder}/playlist_images/. The artwork root directory is created at startup alongside DataFolder and CacheFolder. This also removes the conf dependency from reader_playlist.go since path resolution is now fully encapsulated in the model. Signed-off-by: Deluan <deluan@navidrome.org> * refactor(playlist): streamline artwork image selection logic Signed-off-by: Deluan <deluan@navidrome.org> * refactor: move translation keys, add pt-BR translations Signed-off-by: Deluan <deluan@navidrome.org> * refactor(playlist): rename image_path to image_file Rename the playlist cover art column and field from image_path/ImagePath to image_file/ImageFile across the migration, model, service, tests, and UI. The new name more accurately describes what the field stores (a filename, not a path) and aligns with the existing ImageFiles/IsImageFile naming conventions in the codebase. --------- Signed-off-by: adrbn <128328324+adrbn@users.noreply.github.com> Signed-off-by: Deluan <deluan@navidrome.org> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> Co-authored-by: Deluan Quintão <deluan@navidrome.org>
This commit is contained in:
+7
-1
@@ -218,9 +218,15 @@
|
||||
"makePrivate": "Make Private",
|
||||
"searchOrCreate": "Search playlists or type to create new...",
|
||||
"pressEnterToCreate": "Press Enter to create new playlist",
|
||||
"removeFromSelection": "Remove from selection"
|
||||
"removeFromSelection": "Remove from selection",
|
||||
"uploadCover": "Upload Cover",
|
||||
"removeCover": "Remove Cover"
|
||||
},
|
||||
"message": {
|
||||
"coverUploaded": "Cover art updated",
|
||||
"coverRemoved": "Cover art removed",
|
||||
"coverUploadError": "Error uploading cover art",
|
||||
"coverRemoveError": "Error removing cover art",
|
||||
"duplicate_song": "Add duplicated songs",
|
||||
"song_exist": "There are duplicates being added to the playlist. Would you like to add the duplicates or skip them?",
|
||||
"noPlaylistsFound": "No playlists found",
|
||||
|
||||
@@ -2,16 +2,27 @@ import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardMedia,
|
||||
IconButton,
|
||||
Tooltip,
|
||||
Typography,
|
||||
useMediaQuery,
|
||||
} from '@material-ui/core'
|
||||
import { makeStyles } from '@material-ui/core/styles'
|
||||
import { useTranslate } from 'react-admin'
|
||||
import { useCallback, useState, useEffect } from 'react'
|
||||
import PhotoCameraIcon from '@material-ui/icons/PhotoCamera'
|
||||
import DeleteIcon from '@material-ui/icons/Delete'
|
||||
import { useTranslate, useNotify, useRefresh } from 'react-admin'
|
||||
import { useCallback, useRef, useState, useEffect } from 'react'
|
||||
import Lightbox from 'react-image-lightbox'
|
||||
import 'react-image-lightbox/style.css'
|
||||
import { CollapsibleComment, DurationField, SizeField } from '../common'
|
||||
import {
|
||||
CollapsibleComment,
|
||||
DurationField,
|
||||
SizeField,
|
||||
isWritable,
|
||||
} from '../common'
|
||||
import subsonic from '../subsonic'
|
||||
import { REST_URL } from '../consts'
|
||||
import { httpClient } from '../dataProvider'
|
||||
|
||||
const useStyles = makeStyles(
|
||||
(theme) => ({
|
||||
@@ -55,6 +66,7 @@ const useStyles = makeStyles(
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
position: 'relative',
|
||||
},
|
||||
cover: {
|
||||
objectFit: 'contain',
|
||||
@@ -68,6 +80,31 @@ const useStyles = makeStyles(
|
||||
coverLoading: {
|
||||
opacity: 0.5,
|
||||
},
|
||||
coverOverlay: {
|
||||
position: 'absolute',
|
||||
bottom: 0,
|
||||
right: 0,
|
||||
display: 'flex',
|
||||
gap: '2px',
|
||||
padding: '2px',
|
||||
backgroundColor: 'rgba(0,0,0,0.5)',
|
||||
borderRadius: '4px 0 0 0',
|
||||
opacity: 0,
|
||||
transition: 'opacity 0.2s ease-in-out',
|
||||
'$coverParent:hover &': {
|
||||
opacity: 1,
|
||||
},
|
||||
},
|
||||
overlayButton: {
|
||||
color: '#fff',
|
||||
padding: '4px',
|
||||
'&:hover': {
|
||||
backgroundColor: 'rgba(255,255,255,0.2)',
|
||||
},
|
||||
},
|
||||
overlayIcon: {
|
||||
fontSize: '1.2rem',
|
||||
},
|
||||
title: {
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
@@ -86,14 +123,18 @@ const useStyles = makeStyles(
|
||||
const PlaylistDetails = (props) => {
|
||||
const { record = {} } = props
|
||||
const translate = useTranslate()
|
||||
const notify = useNotify()
|
||||
const refresh = useRefresh()
|
||||
const classes = useStyles()
|
||||
const isDesktop = useMediaQuery((theme) => theme.breakpoints.up('lg'))
|
||||
const [isLightboxOpen, setLightboxOpen] = useState(false)
|
||||
const [imageLoading, setImageLoading] = useState(false)
|
||||
const [imageError, setImageError] = useState(false)
|
||||
const fileInputRef = useRef(null)
|
||||
|
||||
const imageUrl = subsonic.getCoverArtUrl(record, 300, true)
|
||||
const fullImageUrl = subsonic.getCoverArtUrl(record)
|
||||
const canEdit = isWritable(record.ownerId)
|
||||
|
||||
// Reset image state when playlist changes
|
||||
useEffect(() => {
|
||||
@@ -119,6 +160,60 @@ const PlaylistDetails = (props) => {
|
||||
|
||||
const handleCloseLightbox = useCallback(() => setLightboxOpen(false), [])
|
||||
|
||||
const handleUploadClick = useCallback(
|
||||
(e) => {
|
||||
e.stopPropagation()
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.click()
|
||||
}
|
||||
},
|
||||
[fileInputRef],
|
||||
)
|
||||
|
||||
const handleFileChange = useCallback(
|
||||
async (e) => {
|
||||
const file = e.target.files[0]
|
||||
if (!file || !record.id) return
|
||||
|
||||
const formData = new FormData()
|
||||
formData.append('image', file)
|
||||
|
||||
try {
|
||||
await httpClient(`${REST_URL}/playlist/${record.id}/image`, {
|
||||
method: 'POST',
|
||||
headers: new Headers({}),
|
||||
body: formData,
|
||||
})
|
||||
notify('resources.playlist.message.coverUploaded', 'success')
|
||||
refresh()
|
||||
} catch (err) {
|
||||
notify('resources.playlist.message.coverUploadError', 'warning')
|
||||
}
|
||||
|
||||
// Reset file input so the same file can be re-selected
|
||||
e.target.value = ''
|
||||
},
|
||||
[record.id, notify, refresh],
|
||||
)
|
||||
|
||||
const handleRemoveCover = useCallback(
|
||||
async (e) => {
|
||||
e.stopPropagation()
|
||||
if (!record.id) return
|
||||
|
||||
try {
|
||||
await httpClient(`${REST_URL}/playlist/${record.id}/image`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
notify('resources.playlist.message.coverRemoved', 'success')
|
||||
refresh()
|
||||
} catch (err) {
|
||||
notify('resources.playlist.message.coverRemoveError', 'warning')
|
||||
}
|
||||
},
|
||||
[record.id, notify, refresh],
|
||||
)
|
||||
|
||||
return (
|
||||
<Card className={classes.root}>
|
||||
<div className={classes.cardContents}>
|
||||
@@ -138,6 +233,41 @@ const PlaylistDetails = (props) => {
|
||||
cursor: imageError ? 'default' : 'pointer',
|
||||
}}
|
||||
/>
|
||||
{canEdit && (
|
||||
<div className={classes.coverOverlay}>
|
||||
<Tooltip
|
||||
title={translate('resources.playlist.actions.uploadCover')}
|
||||
>
|
||||
<IconButton
|
||||
className={classes.overlayButton}
|
||||
onClick={handleUploadClick}
|
||||
size="small"
|
||||
>
|
||||
<PhotoCameraIcon className={classes.overlayIcon} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
{record.imageFile && (
|
||||
<Tooltip
|
||||
title={translate('resources.playlist.actions.removeCover')}
|
||||
>
|
||||
<IconButton
|
||||
className={classes.overlayButton}
|
||||
onClick={handleRemoveCover}
|
||||
size="small"
|
||||
>
|
||||
<DeleteIcon className={classes.overlayIcon} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
style={{ display: 'none' }}
|
||||
onChange={handleFileChange}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className={classes.details}>
|
||||
<CardContent className={classes.content}>
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
usePermissions,
|
||||
} from 'react-admin'
|
||||
import Switch from '@material-ui/core/Switch'
|
||||
import { Avatar } from '@material-ui/core'
|
||||
import { makeStyles } from '@material-ui/core/styles'
|
||||
import { useMediaQuery } from '@material-ui/core'
|
||||
import {
|
||||
@@ -28,11 +29,17 @@ import {
|
||||
} from '../common'
|
||||
import PlaylistListActions from './PlaylistListActions'
|
||||
import ChangePublicStatusButton from './ChangePublicStatusButton'
|
||||
import subsonic from '../subsonic'
|
||||
|
||||
const useStyles = makeStyles((theme) => ({
|
||||
button: {
|
||||
color: theme.palette.type === 'dark' ? 'white' : undefined,
|
||||
},
|
||||
coverArt: {
|
||||
width: '40px',
|
||||
height: '40px',
|
||||
borderRadius: '4px',
|
||||
},
|
||||
}))
|
||||
|
||||
const PlaylistFilter = (props) => {
|
||||
@@ -119,6 +126,25 @@ const ToggleAutoImport = ({ resource, source }) => {
|
||||
) : null
|
||||
}
|
||||
|
||||
const CoverArtField = () => {
|
||||
const classes = useStyles()
|
||||
const record = useRecordContext()
|
||||
if (!record) return null
|
||||
return (
|
||||
<Avatar
|
||||
src={subsonic.getCoverArtUrl(record, 80, true)}
|
||||
variant="square"
|
||||
className={classes.coverArt}
|
||||
alt={record.name}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
CoverArtField.defaultProps = {
|
||||
label: '',
|
||||
sortable: false,
|
||||
}
|
||||
|
||||
const PlaylistListBulkActions = (props) => {
|
||||
const classes = useStyles()
|
||||
return (
|
||||
@@ -176,6 +202,7 @@ const PlaylistList = (props) => {
|
||||
bulkActionButtons={!isXsmall && <PlaylistListBulkActions />}
|
||||
>
|
||||
<Datagrid rowClick="show" isRowSelectable={(r) => isWritable(r?.ownerId)}>
|
||||
<CoverArtField source="id" />
|
||||
<TextField source="name" />
|
||||
{columns}
|
||||
<Writable>
|
||||
|
||||
Reference in New Issue
Block a user