mirror of
https://github.com/navidrome/navidrome.git
synced 2026-08-31 07:30:32 +00:00
feat(api,ui): implement album metadata batch editing and physical file writing
Added the ability for administrators to edit album-wide metadata directly from the album's "Get Info" dialog. When saved, the changes are applied recursively to every track associated with the album.
This commit is contained in:
parent
874eb6c723
commit
e2b466ecb2
228
server/nativeapi/album_update.go
Normal file
228
server/nativeapi/album_update.go
Normal file
@ -0,0 +1,228 @@
|
|||||||
|
package nativeapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
"github.com/deluan/rest"
|
||||||
|
"github.com/navidrome/navidrome/conf"
|
||||||
|
"github.com/navidrome/navidrome/log"
|
||||||
|
"github.com/navidrome/navidrome/model"
|
||||||
|
"github.com/navidrome/navidrome/server"
|
||||||
|
"github.com/navidrome/navidrome/tagwriter"
|
||||||
|
"github.com/Masterminds/squirrel"
|
||||||
|
)
|
||||||
|
|
||||||
|
type AlbumUpdateRequest struct {
|
||||||
|
Album string `json:"album"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
AlbumArtist string `json:"albumArtist"`
|
||||||
|
Year *int `json:"year"`
|
||||||
|
Genre string `json:"genre"`
|
||||||
|
Comment string `json:"comment"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (api *Router) addAlbumRoute(r chi.Router) {
|
||||||
|
albumConstructor := func(ctx context.Context) rest.Repository {
|
||||||
|
return api.ds.Resource(ctx, model.Album{})
|
||||||
|
}
|
||||||
|
|
||||||
|
r.Route("/album", func(r chi.Router) {
|
||||||
|
r.Get("/", rest.GetAll(albumConstructor))
|
||||||
|
|
||||||
|
r.Route("/{id}", func(r chi.Router) {
|
||||||
|
r.Use(server.URLParamsMiddleware)
|
||||||
|
r.Get("/", rest.Get(albumConstructor))
|
||||||
|
r.Put("/", api.updateAlbum())
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (api *Router) updateAlbum() http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ctx := r.Context()
|
||||||
|
|
||||||
|
bodyBytesDebug, _ := io.ReadAll(r.Body)
|
||||||
|
log.Info(r.Context(), "DEBUG: Raw JSON Received", "json", string(bodyBytesDebug))
|
||||||
|
r.Body = io.NopCloser(bytes.NewBuffer(bodyBytesDebug))
|
||||||
|
|
||||||
|
if !conf.Server.EnableTagEditing {
|
||||||
|
log.Warn(r.Context(), "Tag editing attempt while disabled")
|
||||||
|
http.Error(w, "Tag editing is disabled in configuration", http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
albumID := chi.URLParamFromCtx(ctx, "id")
|
||||||
|
if albumID == "" {
|
||||||
|
log.Warn(r.Context(), "Album ID missing in update request")
|
||||||
|
http.Error(w, "Album ID is required", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Debug(r.Context(), "Fetching Album", "id", albumID)
|
||||||
|
album, err := api.ds.Album(ctx).Get(albumID)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, model.ErrNotFound) {
|
||||||
|
log.Warn(r.Context(), "Album not found", "id", albumID)
|
||||||
|
http.Error(w, "Album not found", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
log.Error(r.Context(), "Failed to retrieve album", "error", err, "id", albumID)
|
||||||
|
http.Error(w, "Failed to retrieve album", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Debug(r.Context(), "Album retrieved", "album_id", album.ID, "name", album.Name, "song_count", album.SongCount)
|
||||||
|
|
||||||
|
log.Debug(r.Context(), "Fetching MediaFiles for album", "albumId", albumID)
|
||||||
|
mediaFiles, err := api.ds.MediaFile(ctx).GetAll(model.QueryOptions{Filters: squirrel.Eq{"album_id": albumID}})
|
||||||
|
if err != nil {
|
||||||
|
log.Error(r.Context(), "Failed to retrieve media files for album", "error", err, "albumId", albumID)
|
||||||
|
http.Error(w, "Failed to retrieve media files", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Info(r.Context(), "Batch update starting", "album_id", albumID, "count", len(mediaFiles))
|
||||||
|
|
||||||
|
if len(mediaFiles) == 0 {
|
||||||
|
log.Warn(r.Context(), "No media files found for album", "albumId", albumID)
|
||||||
|
http.Error(w, "No media files found for this album", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Debug(r.Context(), "Parsing request body", "albumId", albumID)
|
||||||
|
|
||||||
|
bodyBytes, err := io.ReadAll(r.Body)
|
||||||
|
if err != nil {
|
||||||
|
log.Error(r.Context(), "Failed to read request body", "error", err)
|
||||||
|
http.Error(w, "Failed to read request", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
log.Debug(r.Context(), "Raw request body", "body", string(bodyBytes))
|
||||||
|
|
||||||
|
var req AlbumUpdateRequest
|
||||||
|
if err := json.Unmarshal(bodyBytes, &req); err != nil {
|
||||||
|
log.Error(r.Context(), "Failed to decode JSON payload", "error", err)
|
||||||
|
http.Error(w, "Invalid JSON payload", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Debug(r.Context(), "Request body parsed", "album", req.Album, "albumArtist", req.AlbumArtist, "year", req.Year)
|
||||||
|
|
||||||
|
newAlbumName := req.Album
|
||||||
|
newArtist := req.AlbumArtist
|
||||||
|
newYear := req.Year
|
||||||
|
newGenre := req.Genre
|
||||||
|
newComment := req.Comment
|
||||||
|
|
||||||
|
log.Debug(r.Context(), "Local variables assigned", "newAlbumName", newAlbumName, "newArtist", newArtist)
|
||||||
|
|
||||||
|
titleToUse := newAlbumName
|
||||||
|
if titleToUse == "" {
|
||||||
|
titleToUse = req.Name
|
||||||
|
}
|
||||||
|
log.Info(r.Context(), "DEBUG: Title to be used for tracks", "title", titleToUse)
|
||||||
|
|
||||||
|
tw := tagwriter.New()
|
||||||
|
updatedCount := 0
|
||||||
|
failedCount := 0
|
||||||
|
|
||||||
|
for _, mf := range mediaFiles {
|
||||||
|
absPath := mf.AbsolutePath()
|
||||||
|
|
||||||
|
log.Info(r.Context(), "Processing track", "mediaFileId", mf.ID, "path", absPath, "newAlbum", titleToUse)
|
||||||
|
|
||||||
|
tags := make(tagwriter.Tags)
|
||||||
|
tags[tagwriter.TagAlbum] = titleToUse
|
||||||
|
tags[tagwriter.TagAlbumArtist] = newArtist
|
||||||
|
if newYear != nil && *newYear > 0 {
|
||||||
|
tags[tagwriter.TagYear] = strconv.Itoa(*newYear)
|
||||||
|
}
|
||||||
|
if newGenre != "" {
|
||||||
|
tags[tagwriter.TagGenre] = newGenre
|
||||||
|
}
|
||||||
|
if newComment != "" {
|
||||||
|
tags[tagwriter.TagComment] = newComment
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tw.WriteTags(absPath, tags); err != nil {
|
||||||
|
if errors.Is(err, tagwriter.ErrFeatureDisabled) {
|
||||||
|
log.Warn(r.Context(), "Tag writing disabled in config", "error", err)
|
||||||
|
http.Error(w, "Tag editing is disabled in configuration", http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if errors.Is(err, tagwriter.ErrUnsupportedFormat) {
|
||||||
|
log.Warn(r.Context(), "Unsupported file format", "error", err, "path", absPath)
|
||||||
|
http.Error(w, "Unsupported file format", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if errors.Is(err, tagwriter.ErrReadOnlyFile) {
|
||||||
|
log.Warn(r.Context(), "File is read-only", "error", err, "path", absPath)
|
||||||
|
http.Error(w, "File is read-only", http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
log.Error(r.Context(), "Failed to write tags to file", "error", err, "path", absPath, "mediaFileId", mf.ID)
|
||||||
|
failedCount++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.Chtimes(absPath, time.Now(), time.Now()); err != nil {
|
||||||
|
log.Error(r.Context(), "Failed to update file modification time", "error", err, "path", absPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
mf.Album = titleToUse
|
||||||
|
mf.AlbumArtist = newArtist
|
||||||
|
if newYear != nil && *newYear > 0 {
|
||||||
|
mf.Year = *newYear
|
||||||
|
}
|
||||||
|
mf.Genre = newGenre
|
||||||
|
mf.Comment = newComment
|
||||||
|
|
||||||
|
log.Debug(r.Context(), "Updating MediaFile record", "mediaFileId", mf.ID, "album", mf.Album, "albumArtist", mf.AlbumArtist)
|
||||||
|
if err := api.ds.MediaFile(ctx).Put(&mf); err != nil {
|
||||||
|
log.Error(r.Context(), "Failed to update MediaFile in database", "error", err, "mediaFileId", mf.ID)
|
||||||
|
failedCount++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
updatedCount++
|
||||||
|
log.Debug(r.Context(), "Successfully updated media file", "mediaFileId", mf.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.Album != "" {
|
||||||
|
album.Name = req.Album
|
||||||
|
}
|
||||||
|
if req.AlbumArtist != "" {
|
||||||
|
album.AlbumArtist = req.AlbumArtist
|
||||||
|
}
|
||||||
|
if req.Year != nil && *req.Year > 0 {
|
||||||
|
album.MaxYear = *req.Year
|
||||||
|
}
|
||||||
|
if req.Genre != "" {
|
||||||
|
album.Genre = req.Genre
|
||||||
|
}
|
||||||
|
if req.Comment != "" {
|
||||||
|
album.Comment = req.Comment
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := api.ds.Album(ctx).Put(album); err != nil {
|
||||||
|
log.Error(r.Context(), "Failed to update Album in database", "error", err, "albumId", albumID)
|
||||||
|
http.Error(w, "Failed to update album", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Info(r.Context(), "Album batch update completed", "albumId", albumID, "updated", updatedCount, "failed", failedCount)
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
w.Write([]byte(`{"id":"` + albumID + `", "name":"` + album.Name + `", "updated":` + strconv.Itoa(updatedCount) + `, "failed":` + strconv.Itoa(failedCount) + `}`))
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -66,7 +66,7 @@ func (api *Router) routes() http.Handler {
|
|||||||
r.Use(server.UpdateLastAccessMiddleware(api.ds))
|
r.Use(server.UpdateLastAccessMiddleware(api.ds))
|
||||||
api.RX(r, "/user", api.users.NewRepository, true)
|
api.RX(r, "/user", api.users.NewRepository, true)
|
||||||
api.addSongRoute(r)
|
api.addSongRoute(r)
|
||||||
api.R(r, "/album", model.Album{}, false)
|
api.addAlbumRoute(r)
|
||||||
api.addArtistRoute(r)
|
api.addArtistRoute(r)
|
||||||
api.R(r, "/genre", model.Genre{}, false)
|
api.R(r, "/genre", model.Genre{}, false)
|
||||||
api.R(r, "/player", model.Player{}, true)
|
api.R(r, "/player", model.Player{}, true)
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
import React, { useState, useCallback, useEffect } from 'react'
|
||||||
import Table from '@material-ui/core/Table'
|
import Table from '@material-ui/core/Table'
|
||||||
import TableBody from '@material-ui/core/TableBody'
|
import TableBody from '@material-ui/core/TableBody'
|
||||||
import { humanize, underscore } from 'inflection'
|
import { humanize, underscore } from 'inflection'
|
||||||
@ -14,14 +15,24 @@ import {
|
|||||||
TextField,
|
TextField,
|
||||||
useRecordContext,
|
useRecordContext,
|
||||||
useTranslate,
|
useTranslate,
|
||||||
|
useNotify,
|
||||||
|
useRefresh,
|
||||||
} from 'react-admin'
|
} from 'react-admin'
|
||||||
import { makeStyles } from '@material-ui/core/styles'
|
import { makeStyles } from '@material-ui/core/styles'
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
TextField as MuiTextField,
|
||||||
|
CircularProgress,
|
||||||
|
} from '@material-ui/core'
|
||||||
|
import EditIcon from '@material-ui/icons/Edit'
|
||||||
import {
|
import {
|
||||||
ArtistLinkField,
|
ArtistLinkField,
|
||||||
MultiLineTextField,
|
MultiLineTextField,
|
||||||
ParticipantsInfo,
|
ParticipantsInfo,
|
||||||
RangeField,
|
RangeField,
|
||||||
} from '../common'
|
} from '../common'
|
||||||
|
import config from '../config'
|
||||||
|
import httpClient from '../dataProvider/httpClient'
|
||||||
|
|
||||||
const useStyles = makeStyles({
|
const useStyles = makeStyles({
|
||||||
tableCell: {
|
tableCell: {
|
||||||
@ -32,94 +43,246 @@ const useStyles = makeStyles({
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const EDITABLE_FIELDS = ['name', 'albumArtist', 'genre', 'year']
|
||||||
|
|
||||||
const AlbumInfo = (props) => {
|
const AlbumInfo = (props) => {
|
||||||
const classes = useStyles()
|
const classes = useStyles()
|
||||||
const translate = useTranslate()
|
const translate = useTranslate()
|
||||||
const record = useRecordContext(props)
|
const record = useRecordContext(props)
|
||||||
const data = {
|
const notify = useNotify()
|
||||||
name: <TextField source={'name'} />,
|
const refresh = useRefresh()
|
||||||
libraryName: <TextField source="libraryName" />,
|
const [isEditing, setIsEditing] = useState(false)
|
||||||
albumArtist: (
|
const [saving, setSaving] = useState(false)
|
||||||
<ArtistLinkField source="albumArtist" record={record} limit={Infinity} />
|
const [formData, setFormData] = useState({
|
||||||
),
|
name: '',
|
||||||
genre: (
|
albumArtist: '',
|
||||||
<ArrayField source={'genres'}>
|
genre: '',
|
||||||
<SingleFieldList linkType={false}>
|
year: '',
|
||||||
<ChipField source={'name'} />
|
|
||||||
</SingleFieldList>
|
|
||||||
</ArrayField>
|
|
||||||
),
|
|
||||||
date:
|
|
||||||
record?.maxYear && record.maxYear === record.minYear ? (
|
|
||||||
<TextField source={'date'} />
|
|
||||||
) : (
|
|
||||||
<RangeField source={'year'} />
|
|
||||||
),
|
|
||||||
originalDate:
|
|
||||||
record?.maxOriginalYear &&
|
|
||||||
record.maxOriginalYear === record.minOriginalYear ? (
|
|
||||||
<TextField source={'originalDate'} />
|
|
||||||
) : (
|
|
||||||
<RangeField source={'originalYear'} />
|
|
||||||
),
|
|
||||||
releaseDate: <TextField source={'releaseDate'} />,
|
|
||||||
recordLabel: (
|
|
||||||
<FunctionField
|
|
||||||
source={'recordLabel'}
|
|
||||||
render={(record) => record.tags?.recordlabel?.join(', ')}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
catalogNum: <TextField source={'catalogNum'} />,
|
|
||||||
releaseType: (
|
|
||||||
<FunctionField
|
|
||||||
source={'releaseType'}
|
|
||||||
render={(record) => record.tags?.releasetype?.join(', ')}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
media: (
|
|
||||||
<FunctionField
|
|
||||||
source={'media'}
|
|
||||||
render={(record) => record.tags?.media?.join(', ')}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
grouping: (
|
|
||||||
<FunctionField
|
|
||||||
source={'grouping'}
|
|
||||||
render={(record) => record.tags?.grouping?.join(', ')}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
mood: (
|
|
||||||
<FunctionField
|
|
||||||
source={'mood'}
|
|
||||||
render={(record) => record.tags?.mood?.join(', ')}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
compilation: <BooleanField source={'compilation'} />,
|
|
||||||
updatedAt: <DateField source={'updatedAt'} showTime />,
|
|
||||||
comment: <MultiLineTextField source={'comment'} />,
|
|
||||||
}
|
|
||||||
|
|
||||||
const optionalFields = ['comment', 'genre', 'catalogNum']
|
|
||||||
optionalFields.forEach((field) => {
|
|
||||||
!record[field] && delete data[field]
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const optionalTags = [
|
useEffect(() => {
|
||||||
'releaseType',
|
if (record && isEditing) {
|
||||||
|
setFormData({
|
||||||
|
name: record.name || '',
|
||||||
|
albumArtist: record.albumArtist || '',
|
||||||
|
genre: record.genres?.map((g) => g.name).join(' • ') || '',
|
||||||
|
year: record.year || '',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}, [record, isEditing])
|
||||||
|
|
||||||
|
const startEdit = useCallback(() => {
|
||||||
|
setFormData({
|
||||||
|
name: record.name || '',
|
||||||
|
albumArtist: record.albumArtist || '',
|
||||||
|
genre: record.genres?.map((g) => g.name).join(' • ') || '',
|
||||||
|
year: record.year || '',
|
||||||
|
})
|
||||||
|
setIsEditing(true)
|
||||||
|
}, [record])
|
||||||
|
|
||||||
|
const cancelEdit = useCallback(() => {
|
||||||
|
setIsEditing(false)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleFieldChange = useCallback((field) => (event) => {
|
||||||
|
setFormData((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[field]: event.target.value,
|
||||||
|
}))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleSave = useCallback(async () => {
|
||||||
|
if (!record?.id) return
|
||||||
|
|
||||||
|
setSaving(true)
|
||||||
|
const payload = {
|
||||||
|
album: formData.name,
|
||||||
|
albumArtist: formData.albumArtist,
|
||||||
|
genre: formData.genre,
|
||||||
|
year: formData.year ? parseInt(formData.year, 10) : null,
|
||||||
|
}
|
||||||
|
console.log('DEBUG: Sending Payload', payload)
|
||||||
|
|
||||||
|
try {
|
||||||
|
await httpClient(`/api/album/${record.id}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
})
|
||||||
|
notify('Album updated', { type: 'success' })
|
||||||
|
refresh()
|
||||||
|
setFormData({
|
||||||
|
name: payload.album,
|
||||||
|
albumArtist: payload.albumArtist,
|
||||||
|
genre: payload.genre,
|
||||||
|
year: payload.year ? String(payload.year) : '',
|
||||||
|
})
|
||||||
|
setIsEditing(false)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error updating album:', error)
|
||||||
|
notify('Error updating album. Check console for details.', { type: 'error' })
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}, [record, formData, notify, refresh])
|
||||||
|
|
||||||
|
const buildField = (key) => {
|
||||||
|
if (isEditing) {
|
||||||
|
if (EDITABLE_FIELDS.includes(key)) {
|
||||||
|
return (
|
||||||
|
<MuiTextField
|
||||||
|
value={formData[key] || ''}
|
||||||
|
onChange={handleFieldChange(key)}
|
||||||
|
variant="outlined"
|
||||||
|
size="small"
|
||||||
|
fullWidth
|
||||||
|
disabled={saving}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const viewFields = {
|
||||||
|
name: formData.name || <TextField source={'name'} />,
|
||||||
|
libraryName: <TextField source="libraryName" />,
|
||||||
|
albumArtist: formData.albumArtist || (
|
||||||
|
<ArtistLinkField source="albumArtist" record={record} limit={Infinity} />
|
||||||
|
),
|
||||||
|
genre: formData.genre || (
|
||||||
|
<ArrayField source={'genres'}>
|
||||||
|
<SingleFieldList linkType={false}>
|
||||||
|
<ChipField source={'name'} />
|
||||||
|
</SingleFieldList>
|
||||||
|
</ArrayField>
|
||||||
|
),
|
||||||
|
date:
|
||||||
|
record?.maxYear && record.maxYear === record.minYear ? (
|
||||||
|
formData.year ? parseInt(formData.year, 10) : <TextField source={'date'} />
|
||||||
|
) : (
|
||||||
|
<RangeField source={'year'} />
|
||||||
|
),
|
||||||
|
originalDate:
|
||||||
|
record?.maxOriginalYear &&
|
||||||
|
record.maxOriginalYear === record.minOriginalYear ? (
|
||||||
|
<TextField source={'originalDate'} />
|
||||||
|
) : (
|
||||||
|
<RangeField source={'originalYear'} />
|
||||||
|
),
|
||||||
|
releaseDate: <TextField source={'releaseDate'} />,
|
||||||
|
recordLabel: (
|
||||||
|
<FunctionField
|
||||||
|
source={'recordLabel'}
|
||||||
|
render={(record) => record.tags?.recordlabel?.join(', ')}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
catalogNum: <TextField source={'catalogNum'} />,
|
||||||
|
releaseType: (
|
||||||
|
<FunctionField
|
||||||
|
source={'releaseType'}
|
||||||
|
render={(record) => record.tags?.releasetype?.join(', ')}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
media: (
|
||||||
|
<FunctionField
|
||||||
|
source={'media'}
|
||||||
|
render={(record) => record.tags?.media?.join(', ')}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
grouping: (
|
||||||
|
<FunctionField
|
||||||
|
source={'grouping'}
|
||||||
|
render={(record) => record.tags?.grouping?.join(', ')}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
mood: (
|
||||||
|
<FunctionField
|
||||||
|
source={'mood'}
|
||||||
|
render={(record) => record.tags?.mood?.join(', ')}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
compilation: <BooleanField source={'compilation'} />,
|
||||||
|
updatedAt: <DateField source={'updatedAt'} showTime />,
|
||||||
|
comment: <MultiLineTextField source={'comment'} />,
|
||||||
|
}
|
||||||
|
return viewFields[key]
|
||||||
|
}
|
||||||
|
|
||||||
|
const allFields = [
|
||||||
|
'name',
|
||||||
|
'libraryName',
|
||||||
|
'albumArtist',
|
||||||
|
'genre',
|
||||||
|
'date',
|
||||||
|
'originalDate',
|
||||||
|
'releaseDate',
|
||||||
'recordLabel',
|
'recordLabel',
|
||||||
|
'catalogNum',
|
||||||
|
'releaseType',
|
||||||
|
'media',
|
||||||
'grouping',
|
'grouping',
|
||||||
'mood',
|
'mood',
|
||||||
'media',
|
'compilation',
|
||||||
|
'updatedAt',
|
||||||
|
'comment',
|
||||||
]
|
]
|
||||||
optionalTags.forEach((field) => {
|
|
||||||
!record?.tags?.[field.toLowerCase()] && delete data[field]
|
const optionalFields = ['comment', 'genre', 'catalogNum']
|
||||||
|
const optionalTags = ['releaseType', 'recordLabel', 'grouping', 'mood', 'media']
|
||||||
|
const editableExceptions = ['libraryName', 'date', 'originalDate', 'releaseDate', 'recordLabel', 'catalogNum', 'releaseType', 'media', 'grouping', 'mood', 'compilation', 'updatedAt', 'comment']
|
||||||
|
|
||||||
|
let fieldsToShow = allFields.filter((field) => {
|
||||||
|
if (!isEditing && optionalFields.includes(field) && !record[field]) return false
|
||||||
|
if (!isEditing && optionalTags.includes(field)) {
|
||||||
|
if (!record?.tags?.[field.toLowerCase()]) return false
|
||||||
|
}
|
||||||
|
if (isEditing && !EDITABLE_FIELDS.includes(field) && !editableExceptions.includes(field)) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
})
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TableContainer>
|
<TableContainer>
|
||||||
|
<div style={{ textAlign: 'right', marginBottom: 8 }}>
|
||||||
|
{config.enableTagEditing && !isEditing && (
|
||||||
|
<Button
|
||||||
|
startIcon={<EditIcon />}
|
||||||
|
onClick={startEdit}
|
||||||
|
variant="outlined"
|
||||||
|
size="small"
|
||||||
|
>
|
||||||
|
{translate('ra.action.edit')}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{isEditing && (
|
||||||
|
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
|
||||||
|
<Button
|
||||||
|
onClick={cancelEdit}
|
||||||
|
disabled={saving}
|
||||||
|
variant="outlined"
|
||||||
|
size="small"
|
||||||
|
>
|
||||||
|
{translate('ra.action.cancel')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={saving}
|
||||||
|
variant="contained"
|
||||||
|
color="primary"
|
||||||
|
size="small"
|
||||||
|
startIcon={saving ? <CircularProgress size={16} color="inherit" /> : null}
|
||||||
|
>
|
||||||
|
{translate('ra.action.save')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<Table aria-label="album details" size="small">
|
<Table aria-label="album details" size="small">
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{Object.keys(data).map((key) => {
|
{fieldsToShow.map((key) => {
|
||||||
|
const cellContent = buildField(key)
|
||||||
|
if (!cellContent) return null
|
||||||
return (
|
return (
|
||||||
<TableRow key={`${record.id}-${key}`}>
|
<TableRow key={`${record.id}-${key}`}>
|
||||||
<TableCell
|
<TableCell
|
||||||
@ -133,12 +296,12 @@ const AlbumInfo = (props) => {
|
|||||||
:
|
:
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell align="left" className={classes.value}>
|
<TableCell align="left" className={classes.value}>
|
||||||
{data[key]}
|
{cellContent}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
<ParticipantsInfo record={record} classes={classes} />
|
{!isEditing && <ParticipantsInfo record={record} classes={classes} />}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
</TableContainer>
|
</TableContainer>
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user