feat(api,ui): implement track metadata editing and physical file writing

Added the ability for administrators to edit song metadata directly from the
"Get Info" dialog. This feature is opt-in and must be enabled via the
ND_ENABLETAGEDITING configuration flag.

Backend changes:
- Created 'tagwriter' package to handle safe metadata writing for MP3 and FLAC.
- Implemented file locking using 'unix.Flock' to prevent race conditions during
  writes and background scans.
- Added 'PUT /api/song/{id}' endpoint to process metadata updates and
  synchronize changes with the SQLite database.
- Forced UTF-8 encoding for ID3v2 tags to prevent "rune not supported" panics.
- Included automatic 'os.Chtimes' updates to notify the background watcher
  of filesystem changes.

Frontend changes:
- Refactored the "Get Info" dialog to include an "Edit Mode" toggle.
- Integrated interactive Material-UI TextField inputs for editable metadata.
- Implemented optimistic UI updates to ensure the dialog reflects saved
  changes immediately without stale data.
- Added support for 'X-ND-Authorization' headers in the edit API call using
  React-Admin's fetchUtils.
This commit is contained in:
serik.perez 2026-05-02 12:25:09 +02:00
parent 13c48b38a0
commit 874eb6c723
19 changed files with 1534 additions and 93 deletions

View File

@ -82,6 +82,7 @@ type configOptions struct {
EnableStarRating bool
EnableUserEditing bool
EnableArtworkUpload bool
EnableTagEditing bool
MaxImageUploadSize string
EnableSharing bool
ShareURL string
@ -769,6 +770,7 @@ func setViperDefaults() {
viper.SetDefault("enablefavourites", true)
viper.SetDefault("enablestarrating", true)
viper.SetDefault("enableuserediting", true)
viper.SetDefault("enabletagediting", false)
viper.SetDefault("defaulttheme", "Dark")
viper.SetDefault("defaultlanguage", "")
viper.SetDefault("defaultuivolume", consts.DefaultUIVolume)

2
go.mod
View File

@ -9,6 +9,8 @@ require (
github.com/Masterminds/squirrel v1.5.4
github.com/andybalholm/cascadia v1.3.3
github.com/bmatcuk/doublestar/v4 v4.10.0
github.com/bogem/id3v2 v1.1.1
github.com/go-flac/go-flac v0.3.1
github.com/deluan/rest v0.0.0-20211102003136-6260bc399cbf
github.com/deluan/sanitize v0.0.0-20241120162836-fdfd8fdfaa55
github.com/dexterlb/mpvipc v0.0.0-20241005113212-7cdefca0e933

6
go.sum
View File

@ -16,6 +16,8 @@ github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/bmatcuk/doublestar/v4 v4.10.0 h1:zU9WiOla1YA122oLM6i4EXvGW62DvKZVxIe6TYWexEs=
github.com/bmatcuk/doublestar/v4 v4.10.0/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
github.com/bogem/id3v2 v1.1.1 h1:FnjS2vytMeEb39tOMG09uz852MaEccA2A3asRM3XxbE=
github.com/bogem/id3v2 v1.1.1/go.mod h1:D1rDm80qF/ocBU+Ik8U4RKnwMq/oNkkB8vGcnrlMJmM=
github.com/cespare/reflex v0.3.1 h1:N4Y/UmRrjwOkNT0oQQnYsdr6YBxvHqtSfPB4mqOyAKk=
github.com/cespare/reflex v0.3.1/go.mod h1:I+0Pnu2W693i7Hv6ZZG76qHTY0mgUa7uCIfCtikXojE=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
@ -30,6 +32,7 @@ github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/ddliu/go-httpclient v0.5.1/go.mod h1:8QVbjq00YK2f2MQyiKuWMdaKOFRcoD9VuubkNCNOuZo=
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1 h1:5RVFMOWjMyRy8cARdy79nAmgYw3hK/4HUq48LQ6Wwqo=
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40=
github.com/deluan/go-taglib v0.0.0-20260407173416-cf47afbaa67a h1:ZPwh87Xa08FCg5MU5e0Did5WgapEWGxb5d4Je0pLjJw=
@ -81,6 +84,8 @@ github.com/go-chi/httprate v0.15.0 h1:j54xcWV9KGmPf/X4H32/aTH+wBlrvxL7P+SdnRqxh5
github.com/go-chi/httprate v0.15.0/go.mod h1:rzGHhVrsBn3IMLYDOZQsSU4fJNWcjui4fWKJcCId1R4=
github.com/go-chi/jwtauth/v5 v5.4.0 h1:Ieh0xMJsFvqylqJ02/mQHKzbbKO9DYNBh4DPKCwTwYI=
github.com/go-chi/jwtauth/v5 v5.4.0/go.mod h1:w6yjqUUXz1b8+oiJel64Sz1KJwduQM6qUA5QNzO5+bQ=
github.com/go-flac/go-flac v0.3.1 h1:BWA7HdO67S4ZLWSVHCxsDHuedFFu5RiV/wmuhvO6Hxo=
github.com/go-flac/go-flac v0.3.1/go.mod h1:jG9IumOfAXr+7J40x0AiQIbJzXf9Y7+Zs/2CNWe4LMk=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-sql-driver/mysql v1.4.1/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w=
@ -101,6 +106,7 @@ github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7Lk
github.com/gohugoio/hashstructure v0.6.0 h1:7wMB/2CfXoThFYhdWRGv3u3rUM761Cq29CxUW+NltUg=
github.com/gohugoio/hashstructure v0.6.0/go.mod h1:lapVLk9XidheHG1IQ4ZSbyYrXcaILU1ZEP/+vno5rBQ=
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=

View File

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

View File

@ -0,0 +1,174 @@
package nativeapi
import (
"context"
"encoding/json"
"errors"
"net/http"
"strconv"
"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"
)
type SongUpdateRequest struct {
Title string `json:"title"`
Artist string `json:"artist"`
Album string `json:"album"`
AlbumArtist string `json:"albumArtist"`
Year *int `json:"year"`
Genre string `json:"genre"`
TrackNumber *int `json:"trackNumber"`
}
func (api *Router) addSongRoute(r chi.Router) {
constructor := func(ctx context.Context) rest.Repository {
return api.ds.Resource(ctx, model.MediaFile{})
}
r.Route("/song", func(r chi.Router) {
r.Get("/", rest.GetAll(constructor))
r.Post("/", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusMethodNotAllowed)
w.Write([]byte(`{"error": "Method not allowed"}`))
})
r.Route("/{id}", func(r chi.Router) {
r.Use(server.URLParamsMiddleware)
r.Get("/", rest.Get(constructor))
r.Put("/", api.updateSong())
r.Delete("/", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusMethodNotAllowed)
w.Write([]byte(`{"error": "Method not allowed"}`))
})
})
})
}
func (api *Router) updateSong() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
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
}
songID := chi.URLParamFromCtx(ctx, "id")
if songID == "" {
log.Warn(r.Context(), "Song ID missing in update request")
http.Error(w, "Song ID is required", http.StatusBadRequest)
return
}
log.Debug(r.Context(), "Fetching MediaFile", "id", songID)
mf, err := api.ds.MediaFile(ctx).Get(songID)
if err != nil {
if errors.Is(err, model.ErrNotFound) {
log.Warn(r.Context(), "Song not found", "id", songID)
http.Error(w, "Song not found", http.StatusNotFound)
return
}
log.Error(r.Context(), "Failed to retrieve song", "error", err, "id", songID)
http.Error(w, "Failed to retrieve song", http.StatusInternalServerError)
return
}
log.Debug(r.Context(), "Parsing request body", "id", songID)
var req SongUpdateRequest
if err := json.NewDecoder(r.Body).Decode(&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(), "Writing tags to file", "path", mf.AbsolutePath(), "id", songID)
absPath := mf.AbsolutePath()
tags := make(tagwriter.Tags)
if req.Title != "" {
tags[tagwriter.TagTitle] = req.Title
}
if req.Artist != "" {
tags[tagwriter.TagArtist] = req.Artist
}
if req.Album != "" {
tags[tagwriter.TagAlbum] = req.Album
}
if req.AlbumArtist != "" {
tags[tagwriter.TagAlbumArtist] = req.AlbumArtist
}
if req.Year != nil && *req.Year > 0 {
tags[tagwriter.TagYear] = strconv.Itoa(*req.Year)
}
if req.Genre != "" {
tags[tagwriter.TagGenre] = req.Genre
}
if req.TrackNumber != nil && *req.TrackNumber > 0 {
tags[tagwriter.TagTrackNumber] = strconv.Itoa(*req.TrackNumber)
}
tw := tagwriter.New()
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", "error", err, "path", absPath)
http.Error(w, "Failed to write tags: "+err.Error(), http.StatusInternalServerError)
return
}
log.Debug(r.Context(), "Updating MediaFile in database", "id", songID)
if req.Title != "" {
mf.Title = req.Title
}
if req.Artist != "" {
mf.Artist = req.Artist
}
if req.Album != "" {
mf.Album = req.Album
}
if req.AlbumArtist != "" {
mf.AlbumArtist = req.AlbumArtist
}
if req.Year != nil && *req.Year > 0 {
mf.Year = *req.Year
}
if req.Genre != "" {
mf.Genre = req.Genre
}
if req.TrackNumber != nil && *req.TrackNumber > 0 {
mf.TrackNumber = *req.TrackNumber
}
if err := api.ds.MediaFile(ctx).Put(mf); err != nil {
log.Error(r.Context(), "Failed to update database", "error", err, "id", songID)
http.Error(w, "Failed to update database", http.StatusInternalServerError)
return
}
log.Info(r.Context(), "Song updated successfully", "id", songID, "title", mf.Title)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"id":"` + songID + `", "title":"` + mf.Title + `"}`))
}
}

View File

@ -80,6 +80,7 @@ func serveIndex(ds model.DataStore, fs fs.FS, shareInfo *model.Share) http.Handl
"enableInspect": conf.Server.Inspect.Enabled,
"pluginsEnabled": conf.Server.Plugins.Enabled,
"extAuthLogoutURL": conf.Server.ExtAuth.LogoutURL,
"enableTagEditing": conf.Server.EnableTagEditing,
}
if strings.HasPrefix(conf.Server.UILoginBackgroundURL, "/") {
appConfig["loginBackgroundURL"] = path.Join(conf.Server.BasePath, conf.Server.UILoginBackgroundURL)

99
tagwriter/flac.go Normal file
View File

@ -0,0 +1,99 @@
package tagwriter
import (
"encoding/binary"
"fmt"
"os"
"github.com/go-flac/go-flac"
)
func writeFLACTags(filePath string, tags Tags) error {
flacFile, err := flac.ParseFile(filePath)
if err != nil {
return fmt.Errorf("failed to parse FLAC file: %w", err)
}
var vorbisCommentIndex int = -1
for i, block := range flacFile.Meta {
if block.Type == flac.VorbisComment {
vorbisCommentIndex = i
break
}
}
vorbisData := encodeVorbisComments(tags)
if vorbisCommentIndex >= 0 {
flacFile.Meta[vorbisCommentIndex].Data = vorbisData
} else {
flacFile.Meta = append(flacFile.Meta, &flac.MetaDataBlock{
Type: flac.VorbisComment,
Data: vorbisData,
})
}
if err := flacFile.Save(filePath); err != nil {
return fmt.Errorf("failed to save FLAC file: %w", err)
}
return nil
}
func encodeVorbisComments(tags Tags) flac.BlockData {
buf := make([]byte, 0)
vendor := "Navidrome"
vendorBytes := []byte(vendor)
buf = append(buf, encodeUint32LE(uint32(len(vendorBytes)))...)
buf = append(buf, vendorBytes...)
numComments := countNonEmptyTags(tags)
buf = append(buf, encodeUint32LE(uint32(numComments))...)
commentPairs := map[string]string{
"TITLE": TagTitle,
"ARTIST": TagArtist,
"ALBUM": TagAlbum,
"ALBUMARTIST": TagAlbumArtist,
"DATE": TagYear,
"YEAR": TagYear,
"GENRE": TagGenre,
"TRACKNUMBER": TagTrackNumber,
"TRACKTOTAL": TagTrackTotal,
"DISCNUMBER": TagDiscNumber,
"DISCTOTAL": TagDiscTotal,
"COMMENT": TagComment,
}
for vorbisKey, tagKey := range commentPairs {
if value, ok := tags[tagKey]; ok && value != "" {
comment := fmt.Sprintf("%s=%s", vorbisKey, value)
commentBytes := []byte(comment)
buf = append(buf, encodeUint32LE(uint32(len(commentBytes)))...)
buf = append(buf, commentBytes...)
}
}
return buf
}
func countNonEmptyTags(tags Tags) int {
count := 0
for _, v := range tags {
if v != "" {
count++
}
}
return count
}
func encodeUint32LE(n uint32) []byte {
b := make([]byte, 4)
binary.LittleEndian.PutUint32(b, n)
return b
}
func init() {
_ = os.Stdin
}

106
tagwriter/lock.go Normal file
View File

@ -0,0 +1,106 @@
package tagwriter
import (
"fmt"
"os"
"path/filepath"
"sync"
"golang.org/x/sys/unix"
)
var (
lockRegistry = struct {
mu sync.RWMutex
files map[string]*fileLock
}{files: make(map[string]*fileLock)}
)
type fileLock struct {
file *os.File
ref int
}
func LockFile(filePath string) (*fileLock, error) {
absPath, err := abs(filePath)
if err != nil {
return nil, fmt.Errorf("invalid path: %w", err)
}
lockRegistry.mu.Lock()
defer lockRegistry.mu.Unlock()
if existing, ok := lockRegistry.files[absPath]; ok {
existing.ref++
return existing, nil
}
f, err := os.OpenFile(absPath, os.O_RDWR, 0)
if err != nil {
if os.IsPermission(err) {
return nil, fmt.Errorf("permission denied opening file: %w", err)
}
return nil, fmt.Errorf("failed to open file: %w", err)
}
err = unix.Flock(int(f.Fd()), unix.LOCK_EX|unix.LOCK_NB)
if err != nil {
f.Close()
return nil, fmt.Errorf("failed to acquire lock: %w", err)
}
lockRegistry.files[absPath] = &fileLock{file: f, ref: 1}
return lockRegistry.files[absPath], nil
}
func UnlockFile(lock *fileLock) error {
if lock == nil || lock.file == nil {
return nil
}
lockRegistry.mu.Lock()
defer lockRegistry.mu.Unlock()
absPath, err := abs(lock.file.Name())
if err != nil {
return err
}
if existing, ok := lockRegistry.files[absPath]; ok {
existing.ref--
if existing.ref > 0 {
return nil
}
delete(lockRegistry.files, absPath)
}
if err := unix.Flock(int(lock.file.Fd()), unix.LOCK_UN); err != nil {
return fmt.Errorf("failed to release lock: %w", err)
}
return lock.file.Close()
}
func abs(path string) (string, error) {
if path == "" {
return "", fmt.Errorf("empty path")
}
if path[0] == '/' {
return path, nil
}
absPath, err := filepath.Abs(path)
if err != nil {
return "", fmt.Errorf("failed to get absolute path: %w", err)
}
return absPath, nil
}
func ClearLocks() {
lockRegistry.mu.Lock()
defer lockRegistry.mu.Unlock()
for _, fl := range lockRegistry.files {
unix.Flock(int(fl.file.Fd()), unix.LOCK_UN)
fl.file.Close()
}
lockRegistry.files = make(map[string]*fileLock)
}

68
tagwriter/mp3.go Normal file
View File

@ -0,0 +1,68 @@
package tagwriter
import (
"fmt"
"github.com/bogem/id3v2"
)
func writeMP3Tags(filePath string, tags Tags) error {
tagFile, err := id3v2.Open(filePath, id3v2.Options{Parse: true})
if err != nil {
return fmt.Errorf("failed to open MP3 file: %w", err)
}
defer tagFile.Close()
tagFile.SetDefaultEncoding(id3v2.EncodingUTF8)
if title, ok := tags[TagTitle]; ok && title != "" {
tagFile.SetTitle(title)
}
if artist, ok := tags[TagArtist]; ok && artist != "" {
tagFile.SetArtist(artist)
}
if album, ok := tags[TagAlbum]; ok && album != "" {
tagFile.SetAlbum(album)
}
if albumArtist, ok := tags[TagAlbumArtist]; ok && albumArtist != "" {
tagFile.AddTextFrame("TPE1", id3v2.EncodingUTF8, albumArtist)
}
if year, ok := tags[TagYear]; ok && year != "" {
tagFile.SetYear(year)
}
if genre, ok := tags[TagGenre]; ok && genre != "" {
tagFile.SetGenre(genre)
}
if trackNum, ok := tags[TagTrackNumber]; ok && trackNum != "" {
trackTotal, _ := tags[TagTrackTotal]
trackFrame := fmt.Sprintf("%s/%s", trackNum, trackTotal)
tagFile.AddTextFrame("TRCK", id3v2.EncodingUTF8, trackFrame)
}
if discNum, ok := tags[TagDiscNumber]; ok && discNum != "" {
discTotal, _ := tags[TagDiscTotal]
discFrame := fmt.Sprintf("%s/%s", discNum, discTotal)
tagFile.AddTextFrame("TPOS", id3v2.EncodingUTF8, discFrame)
}
if comment, ok := tags[TagComment]; ok && comment != "" {
tagFile.AddCommentFrame(id3v2.CommentFrame{
Language: "eng",
Description: "",
Text: comment,
Encoding: id3v2.EncodingUTF8,
})
}
if err := tagFile.Save(); err != nil {
return fmt.Errorf("failed to save MP3 tags: %w", err)
}
return nil
}

130
tagwriter/tagwriter.go Normal file
View File

@ -0,0 +1,130 @@
package tagwriter
import (
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/log"
)
var (
ErrFeatureDisabled = errors.New("tag editing is disabled in configuration")
ErrUnsupportedFormat = errors.New("unsupported audio file format")
ErrReadOnlyFile = errors.New("file is read-only at the OS level")
ErrPermissionDenied = errors.New("permission denied")
)
type Tags map[string]string
const (
TagTitle = "title"
TagArtist = "artist"
TagAlbum = "album"
TagAlbumArtist = "albumartist"
TagYear = "year"
TagGenre = "genre"
TagTrackNumber = "tracknumber"
TagTrackTotal = "tracktotal"
TagDiscNumber = "discnumber"
TagDiscTotal = "disctotal"
TagComment = "comment"
TagAlbumArt = "albumart"
)
type TagWriter interface {
WriteTags(filePath string, tags Tags) error
}
func New() TagWriter {
return &tagWriter{}
}
type tagWriter struct{}
func (t *tagWriter) WriteTags(filePath string, tags Tags) error {
if !conf.Server.EnableTagEditing {
log.Debug("Tag editing is disabled. Enable with config option 'EnableTagEditing'")
return ErrFeatureDisabled
}
if len(tags) == 0 {
return nil
}
absPath, err := filepath.Abs(filePath)
if err != nil {
return fmt.Errorf("invalid file path: %w", err)
}
if err := t.checkFilePermissions(absPath); err != nil {
return err
}
ext := strings.ToLower(filepath.Ext(absPath))
lock, err := LockFile(absPath)
if err != nil {
return fmt.Errorf("failed to acquire file lock: %w", err)
}
defer func() {
if unlockErr := UnlockFile(lock); unlockErr != nil {
log.Error("Failed to release file lock", "filePath", absPath, "error", unlockErr)
}
}()
var writeErr error
switch ext {
case ".mp3", ".mp2":
writeErr = writeMP3Tags(absPath, tags)
case ".flac":
writeErr = writeFLACTags(absPath, tags)
default:
return ErrUnsupportedFormat
}
if writeErr != nil {
return fmt.Errorf("failed to write tags: %w", writeErr)
}
log.Debug("Tags written successfully", "filePath", absPath, "tags", tags)
return nil
}
func (t *tagWriter) checkFilePermissions(filePath string) error {
info, err := os.Stat(filePath)
if err != nil {
if os.IsNotExist(err) {
return fmt.Errorf("file does not exist: %w", err)
}
return fmt.Errorf("failed to stat file: %w", err)
}
if info.Mode().IsDir() {
return errors.New("path is a directory")
}
if info.Mode().Perm()&0200 == 0 {
log.Warn("File is read-only, cannot write tags", "filePath", filePath)
return ErrReadOnlyFile
}
return nil
}
func SupportedFormats() []string {
return []string{".mp3", ".mp2", ".flac"}
}
func IsSupportedFormat(filePath string) bool {
ext := strings.ToLower(filepath.Ext(filePath))
for _, supported := range SupportedFormats() {
if ext == supported {
return true
}
}
return false
}

View File

@ -0,0 +1,17 @@
package tagwriter
import (
"testing"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/tests"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
func TestTagWriter(t *testing.T) {
tests.Init(t, true)
log.SetLevel(log.LevelFatal)
RegisterFailHandler(Fail)
RunSpecs(t, "TagWriter Suite")
}

145
tagwriter/tagwriter_test.go Normal file
View File

@ -0,0 +1,145 @@
package tagwriter
import (
"os"
"path/filepath"
"github.com/navidrome/navidrome/conf"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("TagWriter", func() {
var tw TagWriter
var testDir string
BeforeEach(func() {
tw = New()
conf.Server.EnableTagEditing = true
var err error
testDir, err = os.MkdirTemp("", "tagwriter-test")
Expect(err).NotTo(HaveOccurred())
})
AfterEach(func() {
os.RemoveAll(testDir)
conf.Server.EnableTagEditing = false
ClearLocks()
})
Describe("WriteTags", func() {
It("returns error when feature is disabled", func() {
conf.Server.EnableTagEditing = false
err := tw.WriteTags("test.mp3", Tags{"title": "Test"})
Expect(err).To(Equal(ErrFeatureDisabled))
})
It("returns error for unsupported formats", func() {
testFile := filepath.Join(testDir, "test.ogg")
f, err := os.Create(testFile)
Expect(err).NotTo(HaveOccurred())
f.Close()
err = tw.WriteTags(testFile, Tags{"title": "Test"})
Expect(err).To(Equal(ErrUnsupportedFormat))
})
It("returns error for non-existent file", func() {
err := tw.WriteTags("/nonexistent/path/test.mp3", Tags{"title": "Test"})
Expect(err).To(HaveOccurred())
})
It("returns error for read-only file", func() {
testFile := filepath.Join(testDir, "readonly.mp3")
f, err := os.Create(testFile)
Expect(err).NotTo(HaveOccurred())
f.Close()
os.Chmod(testFile, 0444)
err = tw.WriteTags(testFile, Tags{"title": "Test"})
Expect(err).To(Equal(ErrReadOnlyFile))
os.Chmod(testFile, 0644)
})
It("returns error for directory", func() {
err := tw.WriteTags(testDir, Tags{"title": "Test"})
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("directory"))
})
It("returns no error for empty tags", func() {
testFile := filepath.Join(testDir, "test.mp3")
f, err := os.Create(testFile)
Expect(err).NotTo(HaveOccurred())
f.Close()
err = tw.WriteTags(testFile, Tags{})
Expect(err).NotTo(HaveOccurred())
})
})
Describe("SupportedFormats", func() {
It("returns supported formats", func() {
formats := SupportedFormats()
Expect(formats).To(ContainElements(".mp3", ".mp2", ".flac"))
})
})
Describe("IsSupportedFormat", func() {
It("returns true for supported formats", func() {
Expect(IsSupportedFormat("test.mp3")).To(BeTrue())
Expect(IsSupportedFormat("test.MP3")).To(BeTrue())
Expect(IsSupportedFormat("test.flac")).To(BeTrue())
Expect(IsSupportedFormat("test.FLAC")).To(BeTrue())
})
It("returns false for unsupported formats", func() {
Expect(IsSupportedFormat("test.ogg")).To(BeFalse())
Expect(IsSupportedFormat("test.wav")).To(BeFalse())
Expect(IsSupportedFormat("test.m4a")).To(BeFalse())
})
})
Describe("File Locking", func() {
It("acquires and releases lock", func() {
testFile := filepath.Join(testDir, "locktest.mp3")
f, err := os.Create(testFile)
Expect(err).NotTo(HaveOccurred())
f.Close()
lock, err := LockFile(testFile)
Expect(err).NotTo(HaveOccurred())
Expect(lock).NotTo(BeNil())
err = UnlockFile(lock)
Expect(err).NotTo(HaveOccurred())
})
It("allows multiple locks from same process", func() {
testFile := filepath.Join(testDir, "multilock.mp3")
f, err := os.Create(testFile)
Expect(err).NotTo(HaveOccurred())
f.Close()
lock1, err := LockFile(testFile)
Expect(err).NotTo(HaveOccurred())
lock2, err := LockFile(testFile)
Expect(err).NotTo(HaveOccurred())
Expect(lock1).To(Equal(lock2))
err = UnlockFile(lock1)
Expect(err).NotTo(HaveOccurred())
err = UnlockFile(lock2)
Expect(err).NotTo(HaveOccurred())
})
It("returns error for non-existent file in LockFile", func() {
_, err := LockFile("/nonexistent/file.mp3")
Expect(err).To(HaveOccurred())
})
})
})

View File

@ -1,4 +1,4 @@
import React, { useState } from 'react'
import React, { useState, useCallback, useEffect } from 'react'
import Table from '@material-ui/core/Table'
import TableBody from '@material-ui/core/TableBody'
import TableCell from '@material-ui/core/TableCell'
@ -12,6 +12,8 @@ import {
FunctionField,
useTranslate,
useRecordContext,
useNotify,
useRefresh,
} from 'react-admin'
import { humanize, underscore } from 'inflection'
import {
@ -23,9 +25,16 @@ import {
} from './index'
import { MultiLineTextField } from './MultiLineTextField'
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 config from '../config'
import { AlbumLinkField } from '../song/AlbumLinkField'
import { Tab, Tabs } from '@material-ui/core'
import httpClient from '../dataProvider/httpClient'
const useStyles = makeStyles({
gain: {
@ -41,13 +50,132 @@ const useStyles = makeStyles({
},
})
const EDITABLE_FIELDS = [
'title',
'artist',
'albumArtist',
'album',
'genre',
'year',
'trackNumber',
]
const READONLY_FIELDS = [
'path',
'libraryName',
'discSubtitle',
'bitRate',
'bitDepth',
'sampleRate',
'channels',
'size',
'updatedAt',
'playCount',
'bpm',
'comment',
'compilation',
'playDate',
'albumGain',
'trackGain',
]
export const SongInfo = (props) => {
const classes = useStyles({ gain: config.enableReplayGain })
const translate = useTranslate()
const record = useRecordContext(props)
const notify = useNotify()
const refresh = useRefresh()
const [tab, setTab] = useState(0)
const [editMode, setEditMode] = useState(false)
const [saving, setSaving] = useState(false)
const [formData, setFormData] = useState({
title: '',
artist: '',
albumArtist: '',
album: '',
genre: '',
year: '',
trackNumber: '',
})
useEffect(() => {
if (record && editMode) {
setFormData({
title: record.title || '',
artist: record.artist || '',
albumArtist: record.albumArtist || '',
album: record.album || '',
genre: record.genres?.map((g) => g.name).join(' • ') || '',
year: record.year || '',
trackNumber: record.trackNumber || '',
})
}
}, [record, editMode])
const startEdit = useCallback(() => {
setFormData({
title: record.title || '',
artist: record.artist || '',
albumArtist: record.albumArtist || '',
album: record.album || '',
genre: record.genres?.map((g) => g.name).join(' • ') || '',
year: record.year || '',
trackNumber: record.trackNumber || '',
})
setEditMode(true)
}, [record])
const cancelEdit = useCallback(() => {
setEditMode(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 = {
title: formData.title,
artist: formData.artist,
album: formData.album,
albumArtist: formData.albumArtist,
genre: formData.genre,
year: formData.year ? parseInt(formData.year, 10) : null,
trackNumber: formData.trackNumber ? parseInt(formData.trackNumber, 10) : null,
}
try {
const response = await httpClient(`/api/song/${record.id}`, {
method: 'PUT',
body: JSON.stringify(payload),
})
console.log('Song update response:', response)
notify('Song updated successfully', { type: 'success' })
refresh()
setEditMode(false)
setFormData({
title: payload.title,
artist: payload.artist,
album: payload.album,
albumArtist: payload.albumArtist,
genre: payload.genre,
year: payload.year ? String(payload.year) : '',
trackNumber: payload.trackNumber ? String(payload.trackNumber) : '',
})
} catch (error) {
console.error('Error updating song:', error)
notify('Error updating song. Check console for details.', { type: 'error' })
} finally {
setSaving(false)
}
}, [record, formData, notify, refresh])
// These are already displayed in other fields or are album-level tags
const excludedTags = [
'genre',
'disctotal',
@ -57,43 +185,89 @@ export const SongInfo = (props) => {
'media',
'albumversion',
]
const data = {
path: <PathField />,
libraryName: <TextField source="libraryName" />,
album: (
<AlbumLinkField source="album" sortByOrder={'ASC'} record={record} />
),
discSubtitle: <TextField source="discSubtitle" />,
albumArtist: (
<ArtistLinkField source="albumArtist" record={record} limit={Infinity} />
),
artist: (
<ArtistLinkField source="artist" record={record} limit={Infinity} />
),
genre: (
<FunctionField render={(r) => r.genres?.map((g) => g.name).join(' • ')} />
),
compilation: <BooleanField source="compilation" />,
bitRate: <BitrateField source="bitRate" />,
bitDepth: <NumberField source="bitDepth" />,
sampleRate: <NumberField source="sampleRate" />,
channels: <NumberField source="channels" />,
size: <SizeField source="size" />,
updatedAt: <DateField source="updatedAt" showTime />,
playCount: <TextField source="playCount" />,
bpm: <NumberField source="bpm" />,
comment: <MultiLineTextField source="comment" />,
}
const roles = []
for (const name of Object.keys(record.participants)) {
if (name === 'albumartist' || name === 'artist') {
continue
const buildRow = (key) => {
if (editMode) {
if (EDITABLE_FIELDS.includes(key)) {
return (
<MuiTextField
value={formData[key] || ''}
onChange={handleFieldChange(key)}
variant="outlined"
size="small"
fullWidth
disabled={saving}
/>
)
}
if (READONLY_FIELDS.includes(key)) {
const readOnlyFields = {
path: <PathField />,
libraryName: <TextField source="libraryName" />,
discSubtitle: <TextField source="discSubtitle" />,
bitRate: <BitrateField source="bitRate" />,
bitDepth: <NumberField source="bitDepth" />,
sampleRate: <NumberField source="sampleRate" />,
channels: <NumberField source="channels" />,
size: <SizeField source="size" />,
updatedAt: <DateField source="updatedAt" showTime />,
playCount: <TextField source="playCount" />,
bpm: <NumberField source="bpm" />,
comment: <MultiLineTextField source="comment" />,
compilation: <BooleanField source="compilation" />,
}
return readOnlyFields[key] || null
}
return null
}
roles.push([name, record.participants[name].length])
const viewFields = {
title: formData.title || <TextField source="title" />,
libraryName: <TextField source="libraryName" />,
album: formData.album || <AlbumLinkField source="album" sortByOrder={'ASC'} record={record} />,
discSubtitle: <TextField source="discSubtitle" />,
albumArtist: formData.albumArtist || <ArtistLinkField source="albumArtist" record={record} limit={Infinity} />,
artist: formData.artist || <ArtistLinkField source="artist" record={record} limit={Infinity} />,
genre: formData.genre || <FunctionField render={(r) => r.genres?.map((g) => g.name).join(' • ')} />,
compilation: <BooleanField source="compilation" />,
bitRate: <BitrateField source="bitRate" />,
bitDepth: <NumberField source="bitDepth" />,
sampleRate: <NumberField source="sampleRate" />,
channels: <NumberField source="channels" />,
size: <SizeField source="size" />,
updatedAt: <DateField source="updatedAt" showTime />,
playCount: <TextField source="playCount" />,
bpm: <NumberField source="bpm" />,
comment: <MultiLineTextField source="comment" />,
year: formData.year ? parseInt(formData.year, 10) : <NumberField source="year" />,
trackNumber: formData.trackNumber ? parseInt(formData.trackNumber, 10) : <NumberField source="trackNumber" />,
}
return viewFields[key] || null
}
const allFields = [
'title',
'artist',
'albumArtist',
'album',
'genre',
'year',
'trackNumber',
'path',
'libraryName',
'discSubtitle',
'bitRate',
'bitDepth',
'sampleRate',
'channels',
'size',
'updatedAt',
'playCount',
'bpm',
'comment',
'compilation',
]
const optionalFields = [
'discSubtitle',
'comment',
@ -102,29 +276,39 @@ export const SongInfo = (props) => {
'bitDepth',
'sampleRate',
]
optionalFields.forEach((field) => {
!record[field] && delete data[field]
const fieldsToShow = allFields.filter((field) => {
if (editMode) return true
if (!record[field] && optionalFields.includes(field.toLowerCase())) return false
if (field === 'playCount' && record.playCount <= 0) return false
return true
})
if (record.playCount > 0) {
data.playDate = <DateField record={record} source="playDate" showTime />
if (editMode && record.playCount > 0) {
if (!fieldsToShow.includes('playDate')) {
fieldsToShow.push('playDate')
}
}
if (config.enableReplayGain) {
data.albumGain = (
<NumberField source="rgAlbumGain" className={classes.gain} />
)
data.trackGain = (
<NumberField source="rgTrackGain" className={classes.gain} />
)
if (config.enableReplayGain && !editMode) {
if (!fieldsToShow.includes('albumGain')) {
fieldsToShow.push('albumGain')
}
if (!fieldsToShow.includes('trackGain')) {
fieldsToShow.push('trackGain')
}
}
const tags = Object.entries(record.tags ?? {}).filter(
(tag) => !excludedTags.includes(tag[0]),
)
const showEditButton = config.enableTagEditing && !editMode
const showSaveCancel = editMode
const showTabs = record.rawTags && !editMode
return (
<TableContainer>
{record.rawTags && (
{showTabs && (
<Tabs value={tab} onChange={(_, value) => setTab(value)}>
<Tab
label={translate(`resources.song.fields.mappedTags`)}
@ -138,16 +322,125 @@ export const SongInfo = (props) => {
/>
</Tabs>
)}
<div
hidden={tab === 1}
id="mapped-tags-body"
aria-labelledby={record.rawTags ? 'mapped-tags-tab' : undefined}
>
<div style={{ textAlign: 'right', marginBottom: 8 }}>
{showEditButton && (
<Button
startIcon={<EditIcon />}
onClick={startEdit}
variant="outlined"
size="small"
>
{translate('ra.action.edit')}
</Button>
)}
{showSaveCancel && (
<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>
{showTabs ? (
<>
<div
hidden={tab !== 0}
id="mapped-tags-body"
aria-labelledby="mapped-tags-tab"
>
<Table aria-label="song details" size="small">
<TableBody>
{fieldsToShow.map((key) => {
const cellContent = buildRow(key)
if (!cellContent) return null
return (
<TableRow key={`${record?.id}-${key}`}>
<TableCell scope="row" className={classes.tableCell}>
{translate(`resources.song.fields.${key}`, {
_: humanize(underscore(key)),
})}
:
</TableCell>
<TableCell align="left" className={classes.value}>
{cellContent}
</TableCell>
</TableRow>
)
})}
{!editMode && <ParticipantsInfo classes={classes} record={record} />}
{tags.length > 0 && !editMode && (
<TableRow key={`${record?.id}-separator`}>
<TableCell scope="row" className={classes.tableCell} />
<TableCell align="left" className={classes.value}>
<h4>{translate(`resources.song.fields.tags`)}</h4>
</TableCell>
</TableRow>
)}
{tags.map(([name, values]) => (
<TableRow key={`${record?.id}-tag-${name}`}>
<TableCell scope="row" className={classes.tableCell}>
{name}:
</TableCell>
<TableCell align="left" className={classes.value}>
{values.join(' • ')}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
<div
hidden={tab !== 1}
id="raw-tags-body"
aria-labelledby="raw-tags-tab"
>
<Table size="small" aria-label="song raw tags">
<TableBody>
<TableRow key={`${record?.id}-raw-path`}>
<TableCell scope="row" className={classes.tableCell}>
{translate(`resources.song.fields.path`)}:
</TableCell>
<TableCell align="left">
<PathField />
</TableCell>
</TableRow>
{Object.entries(record.rawTags || {}).map(([key, value]) => (
<TableRow key={`${record?.id}-raw-${key}`}>
<TableCell scope="row" className={classes.tableCell}>
{key}:
</TableCell>
<TableCell align="left" className={classes.value}>
{value.join(' • ')}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</>
) : (
<Table aria-label="song details" size="small">
<TableBody>
{Object.keys(data).map((key) => {
{fieldsToShow.map((key) => {
const cellContent = buildRow(key)
if (!cellContent) return null
return (
<TableRow key={`${record.id}-${key}`}>
<TableRow key={`${record?.id}-${key}`}>
<TableCell scope="row" className={classes.tableCell}>
{translate(`resources.song.fields.${key}`, {
_: humanize(underscore(key)),
@ -155,25 +448,22 @@ export const SongInfo = (props) => {
:
</TableCell>
<TableCell align="left" className={classes.value}>
{data[key]}
{cellContent}
</TableCell>
</TableRow>
)
})}
<ParticipantsInfo classes={classes} record={record} />
{tags.length > 0 && (
<TableRow key={`${record.id}-separator`}>
<TableCell
scope="row"
className={classes.tableCell}
></TableCell>
{!editMode && <ParticipantsInfo classes={classes} record={record} />}
{tags.length > 0 && !editMode && (
<TableRow key={`${record?.id}-separator`}>
<TableCell scope="row" className={classes.tableCell} />
<TableCell align="left" className={classes.value}>
<h4>{translate(`resources.song.fields.tags`)}</h4>
</TableCell>
</TableRow>
)}
{tags.map(([name, values]) => (
<TableRow key={`${record.id}-tag-${name}`}>
<TableRow key={`${record?.id}-tag-${name}`}>
<TableCell scope="row" className={classes.tableCell}>
{name}:
</TableCell>
@ -184,35 +474,7 @@ export const SongInfo = (props) => {
))}
</TableBody>
</Table>
</div>
{record.rawTags && (
<div
hidden={tab === 0}
id="raw-tags-body"
aria-labelledby="raw-tags-tab"
>
<Table size="small" aria-label="song raw tags">
<TableBody>
<TableRow key={`${record.id}-raw-path`}>
<TableCell scope="row" className={classes.tableCell}>
{translate(`resources.song.fields.path`)}:
</TableCell>
<TableCell align="left">{data.path}</TableCell>
</TableRow>
{Object.entries(record.rawTags).map(([key, value]) => (
<TableRow key={`${record.id}-raw-${key}`}>
<TableCell scope="row" className={classes.tableCell}>
{key}:
</TableCell>
<TableCell align="left" className={classes.value}>
{value.join(' • ')}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
</TableContainer>
)
}
}

View File

@ -43,6 +43,7 @@ const defaultConfig = {
separator: '/',
enableInspect: true,
pluginsEnabled: true,
enableTagEditing: false,
}
let config

View File

@ -52,6 +52,9 @@
"playNext": "Play Next",
"info": "Get Info",
"instantMix": "Instant Mix"
},
"notifications": {
"updated": "Song updated"
}
},
"album": {

View File

@ -0,0 +1,25 @@
import React from 'react'
import { IconButton, Tooltip } from '@material-ui/core'
import EditIcon from '@material-ui/icons/Edit'
import { useTranslate } from 'react-admin'
import { useSongEditor } from './SongEditorContext'
export const SongEditButton = ({ record }) => {
const { openEditor } = useSongEditor()
const translate = useTranslate()
const handleClick = (e) => {
e.stopPropagation()
openEditor(record)
}
return (
<Tooltip title={translate('ra.action.edit', { _: 'Edit' })}>
<IconButton size="small" onClick={handleClick}>
<EditIcon fontSize="small" />
</IconButton>
</Tooltip>
)
}
export default SongEditButton

193
ui/src/song/SongEditor.jsx Normal file
View File

@ -0,0 +1,193 @@
import React, { useState, useEffect, useCallback } from 'react'
import {
useGetOne,
useNotify,
useRefresh,
useTranslate,
} from 'react-admin'
import {
Button,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
TextField,
CircularProgress,
} from '@material-ui/core'
import httpClient from '../dataProvider/httpClient'
export const SongEditor = ({ songId, song: initialSong, onClose }) => {
const [song, setSong] = useState(initialSong || null)
const [formData, setFormData] = useState({
title: '',
artist: '',
album: '',
year: '',
genre: '',
trackNumber: '',
})
const [isSaving, setIsSaving] = useState(false)
const notify = useNotify()
const translate = useTranslate()
const refresh = useRefresh()
const { data: fetchedSong, loading } = useGetOne(
'song',
songId,
{ enabled: !!songId && !initialSong }
)
useEffect(() => {
const source = initialSong || fetchedSong
if (source) {
setSong(source)
setFormData({
title: source.title || '',
artist: source.artist || '',
album: source.album || '',
year: source.year || '',
genre: source.genre || '',
trackNumber: source.trackNumber || '',
})
}
}, [initialSong, fetchedSong])
const handleChange = useCallback((field) => (event) => {
setFormData((prev) => ({
...prev,
[field]: event.target.value,
}))
}, [])
const handleSave = useCallback(async () => {
if (!song) return
setIsSaving(true)
const payload = {
title: formData.title,
artist: formData.artist,
album: formData.album,
year: formData.year ? parseInt(formData.year, 10) : null,
genre: formData.genre,
trackNumber: formData.trackNumber ? parseInt(formData.trackNumber, 10) : null,
}
try {
await httpClient(`/api/v1/song/${song.id}`, {
method: 'PUT',
body: JSON.stringify(payload),
})
notify('resources.song.notifications.updated', 'info', { smart_count: 1 })
refresh()
if (onClose) {
onClose()
}
} catch (error) {
notify('ra.notification.updated', { type: 'warning' })
} finally {
setIsSaving(false)
}
}, [song, formData, notify, refresh, onClose])
const handleClose = useCallback(() => {
if (!isSaving && onClose) {
onClose()
}
}, [isSaving, onClose])
const isOpen = !!song
return (
<Dialog
open={isOpen}
onClose={handleClose}
aria-labelledby="song-editor-dialog"
fullWidth={true}
maxWidth={'sm'}
>
<DialogTitle id="song-editor-dialog">
{translate('resources.song.actions.edit', { _: 'Edit Song' })}
</DialogTitle>
<DialogContent>
{loading ? (
<CircularProgress />
) : (
<>
<TextField
value={formData.title}
onChange={handleChange('title')}
autoFocus
fullWidth
variant={'outlined'}
label={translate('resources.song.fields.title', { _: 'Title' })}
disabled={isSaving}
margin="normal"
/>
<TextField
value={formData.artist}
onChange={handleChange('artist')}
fullWidth
variant={'outlined'}
label={translate('resources.song.fields.artist', { _: 'Artist' })}
disabled={isSaving}
margin="normal"
/>
<TextField
value={formData.album}
onChange={handleChange('album')}
fullWidth
variant={'outlined'}
label={translate('resources.song.fields.album', { _: 'Album' })}
disabled={isSaving}
margin="normal"
/>
<TextField
value={formData.year}
onChange={handleChange('year')}
fullWidth
variant={'outlined'}
label={translate('resources.song.fields.year', { _: 'Year' })}
disabled={isSaving}
margin="normal"
type="number"
/>
<TextField
value={formData.genre}
onChange={handleChange('genre')}
fullWidth
variant={'outlined'}
label={translate('resources.song.fields.genre', { _: 'Genre' })}
disabled={isSaving}
margin="normal"
/>
<TextField
value={formData.trackNumber}
onChange={handleChange('trackNumber')}
fullWidth
variant={'outlined'}
label={translate('resources.song.fields.trackNumber', { _: 'Track #' })}
disabled={isSaving}
margin="normal"
type="number"
/>
</>
)}
</DialogContent>
<DialogActions>
<Button onClick={handleClose} color="primary" disabled={isSaving}>
{translate('ra.action.cancel')}
</Button>
<Button
onClick={handleSave}
color="primary"
disabled={isSaving}
startIcon={isSaving ? <CircularProgress size={20} /> : null}
>
{translate('ra.action.save')}
</Button>
</DialogActions>
</Dialog>
)
}
export default SongEditor

View File

@ -0,0 +1,37 @@
import { useState, useCallback, createContext, useContext } from 'react'
const SongEditorContext = createContext(null)
export const useSongEditor = () => {
const context = useContext(SongEditorContext)
if (!context) {
throw new Error('useSongEditor must be used within SongEditorProvider')
}
return context
}
export const SongEditorProvider = ({ children }) => {
const [songId, setSongId] = useState(null)
const [song, setSong] = useState(null)
const openEditor = useCallback((idOrSong) => {
if (typeof idOrSong === 'object') {
setSong(idOrSong)
setSongId(idOrSong.id)
} else {
setSongId(idOrSong)
setSong(null)
}
}, [])
const closeEditor = useCallback(() => {
setSongId(null)
setSong(null)
}, [])
return (
<SongEditorContext.Provider value={{ songId, song, openEditor, closeEditor }}>
{children}
</SongEditorContext.Provider>
)
}

View File

@ -0,0 +1,170 @@
import React from 'react'
import { useState, useCallback } from 'react'
import {
Edit,
SimpleForm,
TextInput,
useNotify,
useRefresh,
useRedirect,
useMutation,
} from 'react-admin'
import {
Dialog,
DialogActions,
DialogContent,
DialogTitle,
Button,
TextField,
CircularProgress,
} from '@material-ui/core'
import httpClient from '../dataProvider/httpClient'
export const SongEditorDialog = ({ songId, onClose }) => {
const [formData, setFormData] = useState({
title: '',
artist: '',
album: '',
year: '',
genre: '',
trackNumber: '',
})
const [loading, setLoading] = useState(true)
const [isSaving, setIsSaving] = useState(false)
const [initialLoading, setInitialLoading] = useState(true)
const notify = useNotify()
const refresh = useRefresh()
useMutation(
{
type: 'getOne',
resource: 'song',
payload: { id: songId },
},
{
onSuccess: (data) => {
setFormData({
title: data?.title || '',
artist: data?.artist || '',
album: data?.album || '',
year: data?.year || '',
genre: data?.genre || '',
trackNumber: data?.trackNumber || '',
})
setInitialLoading(false)
},
onError: () => {
setInitialLoading(false)
notify('ra.notification.item_not_found', 'warning')
},
}
)
useCallback((field) => (event) => {
setFormData((prev) => ({
...prev,
[field]: event.target.value,
}))
}, [])
const handleSave = async () => {
setIsSaving(true)
const payload = {
title: formData.title,
artist: formData.artist,
album: formData.album,
year: formData.year ? parseInt(formData.year, 10) : null,
genre: formData.genre,
trackNumber: formData.trackNumber ? parseInt(formData.trackNumber, 10) : null,
}
try {
await httpClient(`/api/v1/song/${songId}`, {
method: 'PUT',
body: JSON.stringify(payload),
})
notify('resources.song.notifications.updated', 'info', { smart_count: 1 })
refresh()
if (onClose) onClose()
} catch (error) {
notify('ra.notification.updated', { type: 'warning' })
} finally {
setIsSaving(false)
}
}
return (
<Dialog open={!!songId} onClose={onClose} fullWidth maxWidth="sm">
<DialogTitle>Edit Song</DialogTitle>
<DialogContent>
{initialLoading ? (
<CircularProgress />
) : (
<>
<TextField
value={formData.title}
onChange={(e) => setFormData({ ...formData, title: e.target.value })}
fullWidth
variant="outlined"
label="Title"
margin="normal"
/>
<TextField
value={formData.artist}
onChange={(e) => setFormData({ ...formData, artist: e.target.value })}
fullWidth
variant="outlined"
label="Artist"
margin="normal"
/>
<TextField
value={formData.album}
onChange={(e) => setFormData({ ...formData, album: e.target.value })}
fullWidth
variant="outlined"
label="Album"
margin="normal"
/>
<TextField
value={formData.year}
onChange={(e) => setFormData({ ...formData, year: e.target.value })}
fullWidth
variant="outlined"
label="Year"
margin="normal"
type="number"
/>
<TextField
value={formData.genre}
onChange={(e) => setFormData({ ...formData, genre: e.target.value })}
fullWidth
variant="outlined"
label="Genre"
margin="normal"
/>
<TextField
value={formData.trackNumber}
onChange={(e) => setFormData({ ...formData, trackNumber: e.target.value })}
fullWidth
variant="outlined"
label="Track #"
margin="normal"
type="number"
/>
</>
)}
</DialogContent>
<DialogActions>
<Button onClick={onClose} disabled={isSaving}>
Cancel
</Button>
<Button onClick={handleSave} disabled={isSaving} startIcon={isSaving ? <CircularProgress size={20} /> : null}>
Save
</Button>
</DialogActions>
</Dialog>
)
}
export default SongEditorDialog