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:
serik.perez 2026-05-02 15:06:44 +02:00
parent 874eb6c723
commit e2b466ecb2
3 changed files with 466 additions and 75 deletions

View 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) + `}`))
}
}

View File

@ -66,7 +66,7 @@ func (api *Router) routes() http.Handler {
r.Use(server.UpdateLastAccessMiddleware(api.ds))
api.RX(r, "/user", api.users.NewRepository, true)
api.addSongRoute(r)
api.R(r, "/album", model.Album{}, false)
api.addAlbumRoute(r)
api.addArtistRoute(r)
api.R(r, "/genre", model.Genre{}, false)
api.R(r, "/player", model.Player{}, true)

View File

@ -1,3 +1,4 @@
import React, { useState, useCallback, useEffect } from 'react'
import Table from '@material-ui/core/Table'
import TableBody from '@material-ui/core/TableBody'
import { humanize, underscore } from 'inflection'
@ -14,14 +15,24 @@ import {
TextField,
useRecordContext,
useTranslate,
useNotify,
useRefresh,
} from 'react-admin'
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 {
ArtistLinkField,
MultiLineTextField,
ParticipantsInfo,
RangeField,
} from '../common'
import config from '../config'
import httpClient from '../dataProvider/httpClient'
const useStyles = makeStyles({
tableCell: {
@ -32,94 +43,246 @@ const useStyles = makeStyles({
},
})
const EDITABLE_FIELDS = ['name', 'albumArtist', 'genre', 'year']
const AlbumInfo = (props) => {
const classes = useStyles()
const translate = useTranslate()
const record = useRecordContext(props)
const data = {
name: <TextField source={'name'} />,
libraryName: <TextField source="libraryName" />,
albumArtist: (
<ArtistLinkField source="albumArtist" record={record} limit={Infinity} />
),
genre: (
<ArrayField source={'genres'}>
<SingleFieldList linkType={false}>
<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 notify = useNotify()
const refresh = useRefresh()
const [isEditing, setIsEditing] = useState(false)
const [saving, setSaving] = useState(false)
const [formData, setFormData] = useState({
name: '',
albumArtist: '',
genre: '',
year: '',
})
const optionalTags = [
'releaseType',
useEffect(() => {
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',
'catalogNum',
'releaseType',
'media',
'grouping',
'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 (
<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">
<TableBody>
{Object.keys(data).map((key) => {
{fieldsToShow.map((key) => {
const cellContent = buildField(key)
if (!cellContent) return null
return (
<TableRow key={`${record.id}-${key}`}>
<TableCell
@ -133,12 +296,12 @@ const AlbumInfo = (props) => {
:
</TableCell>
<TableCell align="left" className={classes.value}>
{data[key]}
{cellContent}
</TableCell>
</TableRow>
)
})}
<ParticipantsInfo record={record} classes={classes} />
{!isEditing && <ParticipantsInfo record={record} classes={classes} />}
</TableBody>
</Table>
</TableContainer>