Merge 1dd5f4e781415759231b7067778df5f9f74ca0e9 into 838ceee26d6f95dc998b6810d2008688b899c761

This commit is contained in:
Serikpg 2026-06-17 09:58:43 +00:00 committed by GitHub
commit 80bbcf6b3d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
24 changed files with 3104 additions and 168 deletions

View File

@ -83,6 +83,7 @@ type configOptions struct {
EnableStarRating bool
EnableUserEditing bool
EnableArtworkUpload bool
EnableTagEditing bool
MaxImageUploadSize string
EnableSharing bool
ShareURL string
@ -781,6 +782,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-20260511232939-ccd334abae3a h1:L5E3uF4hKLEqoEYT0tXXuFH6c3PEEzQSWLfTqF5Lpqw=
@ -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

@ -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

@ -65,8 +65,8 @@ 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.R(r, "/album", model.Album{}, false)
api.addSongRoute(r)
api.addAlbumRoute(r)
api.addArtistRoute(r)
api.R(r, "/genre", model.Genre{}, false)
api.R(r, "/player", model.Player{}, true)

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)
}

426
tagwriter/m4a.go Normal file
View File

@ -0,0 +1,426 @@
package tagwriter
import (
"bytes"
"encoding/binary"
"errors"
"fmt"
"os"
"strconv"
"time"
)
func writeM4ATags(filePath string, tags Tags) error {
f, err := os.OpenFile(filePath, os.O_RDWR, 0)
if err != nil {
return fmt.Errorf("failed to open M4A file: %w", err)
}
defer f.Close()
fileInfo, err := f.Stat()
if err != nil {
return fmt.Errorf("failed to stat file: %w", err)
}
originalSize := fileInfo.Size()
atoms, err := parseMP4Atoms(f)
if err != nil {
return fmt.Errorf("failed to parse MP4 atoms: %w", err)
}
ilstAtom := findILSTAtom(atoms)
metadataData, err := encodeILSTMetadata(tags)
if err != nil {
return fmt.Errorf("failed to encode metadata: %w", err)
}
if len(metadataData) == 0 {
return nil
}
newFileSize := originalSize
if ilstAtom != nil {
oldILSTSize := calculateAtomSize(int(ilstAtom.DataSize))
newILSTSize := calculateAtomSize(len(metadataData))
delta := int64(newILSTSize) - int64(oldILSTSize)
newFileSize = originalSize + delta
} else {
newFileSize = originalSize + int64(calculateAtomSize(len(metadataData))+8)
}
if newFileSize > originalSize {
if err := f.Truncate(newFileSize); err != nil {
return fmt.Errorf("failed to extend file: %w", err)
}
}
if ilstAtom != nil {
oldSize := calculateAtomSize(int(ilstAtom.DataSize))
newSize := calculateAtomSize(len(metadataData))
delta := int(newSize) - int(oldSize)
if err := shiftDataAfter(f, ilstAtom.Offset+8+int64(oldSize), int64(delta)); err != nil {
return fmt.Errorf("failed to shift data: %w", err)
}
if err := writeILSTAtom(f, ilstAtom.Offset, metadataData); err != nil {
return fmt.Errorf("failed to write ilst atom: %w", err)
}
} else {
moovAtom := findMoovAtom(atoms)
if moovAtom == nil {
return errors.New("missing moov atom")
}
insertionOffset := moovAtom.Offset + 8
if err := shiftDataAfter(f, insertionOffset, int64(calculateAtomSize(len(metadataData))+8)); err != nil {
return fmt.Errorf("failed to shift data for new atom: %w", err)
}
newILSTOffset := insertionOffset
if err := writeFullAtom(f, newILSTOffset, []byte("ilst"), metadataData); err != nil {
return fmt.Errorf("failed to write new ilst atom: %w", err)
}
}
updateFileTimes(filePath)
return nil
}
type mp4Atom struct {
Type [4]byte
Size uint32
DataSize uint32
Offset int64
Children []mp4Atom
}
func parseMP4Atoms(f *os.File) ([]mp4Atom, error) {
var atoms []mp4Atom
offset := int64(0)
for {
header := make([]byte, 8)
n, err := f.ReadAt(header, offset)
if err != nil || n < 8 {
break
}
size := binary.BigEndian.Uint32(header[:4])
atomType := [4]byte{}
copy(atomType[:], header[4:8])
if size == 0 {
break
}
if size == 1 {
extendedSize := make([]byte, 8)
if _, err := f.ReadAt(extendedSize, offset+8); err != nil || len(extendedSize) < 8 {
break
}
size = binary.BigEndian.Uint32(extendedSize[4:8])
}
var dataSize uint32
if size >= 8 {
dataSize = size - 8
}
atom := mp4Atom{
Type: atomType,
Size: size,
DataSize: dataSize,
Offset: offset,
}
if isContainerAtom(atomType) {
childOffset := offset + 8
childEnd := offset + int64(size)
for childOffset < childEnd {
childHeader := make([]byte, 8)
m, err := f.ReadAt(childHeader, childOffset)
if err != nil || m < 8 {
break
}
childSize := binary.BigEndian.Uint32(childHeader[:4])
if childSize == 0 {
break
}
childType := [4]byte{}
copy(childType[:], childHeader[4:8])
if isContainerAtom(childType) {
childAtoms, err := parseContainerAtom(f, childOffset)
if err == nil {
atom.Children = append(atom.Children, childAtoms...)
}
} else {
childAtom := mp4Atom{
Type: childType,
Size: childSize,
DataSize: childSize - 8,
Offset: childOffset,
}
atom.Children = append(atom.Children, childAtom)
}
childOffset += int64(childSize)
}
}
atoms = append(atoms, atom)
offset += int64(size)
}
return atoms, nil
}
func parseContainerAtom(f *os.File, offset int64) ([]mp4Atom, error) {
var atoms []mp4Atom
header := make([]byte, 8)
if _, err := f.ReadAt(header, offset); err != nil || len(header) < 8 {
return nil, err
}
parentSize := binary.BigEndian.Uint32(header[:4])
childEnd := offset + int64(parentSize) - 8
childOffset := offset + 8
for childOffset < childEnd {
childHeader := make([]byte, 8)
n, err := f.ReadAt(childHeader, childOffset)
if err != nil || n < 8 {
break
}
childSize := binary.BigEndian.Uint32(childHeader[:4])
if childSize == 0 {
break
}
childType := [4]byte{}
copy(childType[:], childHeader[4:8])
atom := mp4Atom{
Type: childType,
Size: childSize,
DataSize: childSize - 8,
Offset: childOffset,
}
atoms = append(atoms, atom)
childOffset += int64(childSize)
}
return atoms, nil
}
func isContainerAtom(atomType [4]byte) bool {
containerTypes := map[string]bool{
"moov": true,
"trak": true,
"mdia": true,
"minf": true,
"dinf": true,
"stbl": true,
"udta": true,
"ilst": true,
"meta": true,
"hdlr": true,
}
return containerTypes[string(atomType[:])]
}
func findMoovAtom(atoms []mp4Atom) *mp4Atom {
for i := range atoms {
if bytes.Equal(atoms[i].Type[:], []byte("moov")) {
return &atoms[i]
}
}
return nil
}
func findILSTAtom(atoms []mp4Atom) *mp4Atom {
for i := range atoms {
if bytes.Equal(atoms[i].Type[:], []byte("ilst")) {
return &atoms[i]
}
if len(atoms[i].Children) > 0 {
if child := findILSTAtom(atoms[i].Children); child != nil {
return child
}
}
}
return nil
}
func encodeILSTMetadata(tags Tags) ([]byte, error) {
data := bytes.NewBuffer(nil)
metadataPairs := map[string]string{
"\xa9nam": TagTitle,
"\xa9ART": TagArtist,
"\xa9alb": TagAlbum,
"\xa2A2": TagAlbumArtist,
"\xa9day": TagYear,
"\xa9gen": TagGenre,
"trkn": TagTrackNumber,
"disk": TagDiscNumber,
"cnmt": TagComment,
}
order := []string{"\xa9nam", "\xa9ART", "\xa9alb", "\xa2A2", "\xa9day", "\xa9gen", "trkn", "disk", "cnmt"}
for _, key := range order {
tagKey := metadataPairs[key]
if value, ok := tags[tagKey]; ok && value != "" {
atomData := encodeMP4Value(key, value, tagKey)
if len(atomData) > 0 {
data.Write(atomData)
}
}
}
if data.Len() == 0 {
return nil, nil
}
return data.Bytes(), nil
}
func encodeMP4Value(atomType, value, tagKey string) []byte {
var data []byte
switch tagKey {
case TagTrackNumber, TagDiscNumber:
data = encodeIntegerList(value, atomType)
case TagComment:
data = encodeUTF8Text(value, atomType)
default:
data = encodeUTF8Text(value, atomType)
}
if len(data) == 0 {
return nil
}
atomSize := uint32(len(data)) + 8
atom := make([]byte, 8)
binary.BigEndian.PutUint32(atom[0:4], atomSize)
copy(atom[4:8], []byte(atomType))
return append(atom, data...)
}
func encodeUTF8Text(value, atomType string) []byte {
data := bytes.NewBuffer(nil)
locale := []byte{0x00, 0x65, 0x6E, 0x67}
switch atomType {
case "\xa9nam", "\xa9ART", "\xa9alb", "\xa2A2", "\xa9day", "\xa9gen":
data.Write(locale)
data.WriteString(value)
data.WriteByte(0x00)
default:
data.Write(locale)
data.WriteString(value)
data.WriteByte(0x00)
}
return data.Bytes()
}
func encodeIntegerList(value, atomType string) []byte {
data := bytes.NewBuffer(nil)
var num, total int
fmt.Sscanf(value, "%d/%d", &num, &total)
if total == 0 {
num, _ = strconv.Atoi(value)
}
atomData := make([]byte, 4)
atomData[0] = 0x00
binary.BigEndian.PutUint16(atomData[2:], uint16(num))
data.Write(atomData)
if total > 0 {
totalData := make([]byte, 4)
totalData[0] = 0x00
binary.BigEndian.PutUint16(totalData[2:], uint16(total))
data.Write(totalData)
}
return data.Bytes()
}
func calculateAtomSize(dataSize int) int {
return dataSize + 8
}
func writeFullAtom(f *os.File, offset int64, atomType []byte, data []byte) error {
atomSize := uint32(len(data)) + 8
header := make([]byte, 8)
binary.BigEndian.PutUint32(header[0:4], atomSize)
copy(header[4:8], atomType)
if _, err := f.WriteAt(header, offset); err != nil {
return err
}
if _, err := f.WriteAt(data, offset+8); err != nil {
return err
}
return nil
}
func writeILSTAtom(f *os.File, offset int64, data []byte) error {
return writeFullAtom(f, offset, []byte("ilst"), data)
}
func shiftDataAfter(f *os.File, position int64, delta int64) error {
if delta <= 0 {
return nil
}
fileSize, err := f.Seek(0, os.SEEK_END)
if err != nil {
return err
}
buf := make([]byte, 8192)
for offset := fileSize; offset > position; offset -= int64(len(buf)) {
if offset < position+int64(len(buf)) {
buf = buf[:offset-position]
offset = position
}
dest := offset + delta
_, err := f.ReadAt(buf, offset)
if err != nil {
return err
}
_, err = f.WriteAt(buf, dest)
if err != nil {
return err
}
}
return nil
}
func updateFileTimes(filePath string) error {
now := time.Now()
return os.Chtimes(filePath, now, now)
}
func init() {
_ = os.Stdin
}

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
}

396
tagwriter/ogg.go Normal file
View File

@ -0,0 +1,396 @@
package tagwriter
import (
"bytes"
"encoding/binary"
"errors"
"fmt"
"io"
"os"
)
func writeOGGTags(filePath string, tags Tags) error {
f, err := os.OpenFile(filePath, os.O_RDWR, 0)
if err != nil {
return fmt.Errorf("failed to open OGG file: %w", err)
}
defer f.Close()
header, err := readOGGPageHeader(f)
if err != nil {
return fmt.Errorf("invalid OGG file: %w", err)
}
if !bytes.Equal(header.Magic[:4], []byte("OggS")) {
return errors.New("invalid OGG file: missing OGGS header")
}
pages, err := parseOGGPages(f)
if err != nil {
return fmt.Errorf("failed to parse OGG pages: %w", err)
}
vorbisCommentPage, commentSegment, err := findVorbisCommentPage(pages, f)
if err != nil {
return fmt.Errorf("failed to find Vorbis comment: %w", err)
}
vorbisData := encodeVorbisCommentsOgg(tags)
if len(vorbisData) == 0 {
return nil
}
if vorbisCommentPage != nil {
if err := updateVorbisComment(f, vorbisCommentPage, commentSegment, vorbisData); err != nil {
return fmt.Errorf("failed to update Vorbis comment: %w", err)
}
} else {
if err := insertVorbisComment(f, header, vorbisData); err != nil {
return fmt.Errorf("failed to insert Vorbis comment: %w", err)
}
}
recalculateOGGChecksums(f)
updateFileTimes(filePath)
return nil
}
type oggPageHeader struct {
Magic [4]byte
Version byte
HeaderType byte
GranulePos uint64
Serial uint32
PageSeq uint32
Checksum uint32
PageSegments byte
}
type oggPage struct {
Header oggPageHeader
Offset int64
SegmentSizes []byte
SegmentsStart int64
DataStart int64
}
func readOGGPageHeader(f *os.File) (oggPageHeader, error) {
header := make([]byte, 27)
_, err := f.Read(header)
if err != nil {
return oggPageHeader{}, err
}
var h oggPageHeader
copy(h.Magic[:], header[0:4])
h.Version = header[4]
h.HeaderType = header[5]
h.GranulePos = binary.LittleEndian.Uint64(header[6:14])
h.Serial = binary.LittleEndian.Uint32(header[14:18])
h.PageSeq = binary.LittleEndian.Uint32(header[18:22])
h.Checksum = binary.LittleEndian.Uint32(header[22:26])
h.PageSegments = header[26]
return h, nil
}
func parseOGGPages(f *os.File) ([]oggPage, error) {
var pages []oggPage
offset := int64(0)
for {
header, err := readOGGPageHeaderAt(f, offset)
if err != nil {
if errors.Is(err, io.EOF) {
break
}
return nil, err
}
segmentSizes := make([]byte, header.PageSegments)
if _, err := f.ReadAt(segmentSizes, offset+27); err != nil {
return nil, err
}
segmentsStart := offset + 27 + int64(header.PageSegments)
dataStart := segmentsStart
var totalDataSize int64
for _, segSize := range segmentSizes {
totalDataSize += int64(segSize)
}
page := oggPage{
Header: header,
Offset: offset,
SegmentSizes: segmentSizes,
SegmentsStart: segmentsStart,
DataStart: dataStart,
}
pages = append(pages, page)
pageSize := segmentsStart + totalDataSize - offset
offset += pageSize
if pageSize == 0 {
break
}
}
return pages, nil
}
func readOGGPageHeaderAt(f *os.File, offset int64) (oggPageHeader, error) {
header := make([]byte, 27)
_, err := f.ReadAt(header, offset)
if err != nil {
return oggPageHeader{}, err
}
var h oggPageHeader
copy(h.Magic[:], header[0:4])
h.Version = header[4]
h.HeaderType = header[5]
h.GranulePos = binary.LittleEndian.Uint64(header[6:14])
h.Serial = binary.LittleEndian.Uint32(header[14:18])
h.PageSeq = binary.LittleEndian.Uint32(header[18:22])
h.Checksum = binary.LittleEndian.Uint32(header[22:26])
h.PageSegments = header[26]
return h, nil
}
func findVorbisCommentPage(pages []oggPage, f *os.File) (*oggPage, int, error) {
for i, page := range pages {
if page.Header.HeaderType&0x02 == 0 {
continue
}
if len(page.SegmentSizes) == 0 {
continue
}
data := make([]byte, page.SegmentSizes[0])
if _, err := f.ReadAt(data, page.DataStart); err != nil {
continue
}
if len(data) >= 7 && bytes.Equal(data[0:7], []byte("vorbis")) {
return &pages[i], 0, nil
}
var cumulative int
for segIdx, segSize := range page.SegmentSizes {
cumulative += int(segSize)
if cumulative >= 7 {
headerData := make([]byte, segSize)
readOffset := page.DataStart + int64(cumulative - int(segSize))
f.ReadAt(headerData, readOffset)
if bytes.Equal(headerData[:7], []byte("vorbis")) {
return &pages[i], segIdx, nil
}
break
}
}
}
return nil, 0, errors.New("Vorbis comment header not found - creating new header")
}
func encodeVorbisCommentsOgg(tags Tags) []byte {
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 updateVorbisComment(f *os.File, page *oggPage, segmentIdx int, vorbisData []byte) error {
pageDataSize := int64(0)
for _, segSize := range page.SegmentSizes {
pageDataSize += int64(segSize)
}
oldDataSize := int64(0)
for i := segmentIdx; i < len(page.SegmentSizes); i++ {
oldDataSize += int64(page.SegmentSizes[i])
}
delta := int64(len(vorbisData)) - oldDataSize
if delta == 0 {
dataOffset := page.DataStart + pageDataSize - oldDataSize
_, err := f.WriteAt(vorbisData, dataOffset)
return err
}
if delta > 0 {
fileSize, err := f.Seek(0, os.SEEK_END)
if err != nil {
return err
}
pageEnd := page.Offset + 27 + int64(page.Header.PageSegments) + pageDataSize
moveBuf := make([]byte, 4096)
for pos := fileSize - 4096; pos >= pageEnd; pos -= 4096 {
_, err := f.ReadAt(moveBuf, pos)
if err != nil {
return err
}
_, err = f.WriteAt(moveBuf, pos+delta)
if err != nil {
return err
}
}
if fileSize-pageEnd < 4096 {
remaining := make([]byte, fileSize-pageEnd)
f.ReadAt(remaining, pageEnd)
f.WriteAt(remaining, pageEnd+delta)
}
}
dataOffset := page.DataStart
_, err := f.WriteAt(vorbisData, dataOffset)
return err
}
func insertVorbisComment(f *os.File, firstPage oggPageHeader, vorbisData []byte) error {
commentHeader := createVorbisCommentHeader(vorbisData)
commentData := append(commentHeader, vorbisData...)
newFirstPage := firstPage
newFirstPage.HeaderType |= 0x01
newPageSize := 27 + 1 + int64(len(commentData))
pageData := make([]byte, 0, newPageSize)
pageData = append(pageData, []byte("OggS")...)
pageData = append(pageData, newFirstPage.Version)
pageData = append(pageData, newFirstPage.HeaderType)
pageData = append(pageData, make([]byte, 8)...)
serialBytes := make([]byte, 4)
binary.LittleEndian.PutUint32(serialBytes, newFirstPage.Serial)
pageData = append(pageData, serialBytes...)
seqBytes := make([]byte, 4)
binary.LittleEndian.PutUint32(seqBytes, newFirstPage.PageSeq)
pageData = append(pageData, seqBytes...)
pageData = append(pageData, make([]byte, 4)...)
pageData = append(pageData, 1)
pageData = append(pageData, byte(len(commentData)))
pageData = append(pageData, commentData...)
_, err := f.WriteAt(pageData, 0)
return err
}
func createVorbisCommentHeader(data []byte) []byte {
header := make([]byte, 7)
copy(header, []byte("vorbis"))
return header
}
func recalculateOGGChecksums(f *os.File) error {
offset := int64(0)
for {
header := make([]byte, 27)
n, err := f.ReadAt(header, offset)
if err != nil || n < 27 {
if errors.Is(err, io.EOF) {
break
}
return err
}
if !bytes.Equal(header[0:4], []byte("OggS")) {
break
}
pageSegments := header[26]
segmentSizes := make([]byte, pageSegments)
f.ReadAt(segmentSizes, offset+27)
var pageSize int64 = 27 + int64(pageSegments)
for _, segSize := range segmentSizes {
pageSize += int64(segSize)
}
pageData := make([]byte, pageSize)
f.ReadAt(pageData, offset)
checksum := computeCRC(pageData)
checksumBytes := make([]byte, 4)
binary.LittleEndian.PutUint32(checksumBytes, checksum)
f.WriteAt(checksumBytes, offset+22)
offset += pageSize
if offset <= 0 {
break
}
}
return nil
}
func computeCRC(data []byte) uint32 {
crcTable := make([]uint32, 256)
for i := range crcTable {
c := uint32(i)
for j := 0; j < 8; j++ {
if c&1 != 0 {
c = 0xedb88320 ^ (c >> 1)
} else {
c = c >> 1
}
}
crcTable[i] = c
}
var crc uint32 = 0xffffffff
for _, b := range data {
crc = crcTable[byte(crc)^b] ^ (crc >> 8)
}
return crc ^ 0xffffffff
}
func init() {
_ = os.Stdin
}

136
tagwriter/tagwriter.go Normal file
View File

@ -0,0 +1,136 @@
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)
case ".wav", ".wave":
writeErr = writeWAVTags(absPath, tags)
case ".m4a", ".mp4":
writeErr = writeM4ATags(absPath, tags)
case ".ogg":
writeErr = writeOGGTags(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", ".wav", ".wave", ".m4a", ".mp4", ".ogg"}
}
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")
}

152
tagwriter/tagwriter_test.go Normal file
View File

@ -0,0 +1,152 @@
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.xyz")
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())
Expect(IsSupportedFormat("test.wav")).To(BeTrue())
Expect(IsSupportedFormat("test.WAV")).To(BeTrue())
Expect(IsSupportedFormat("test.wave")).To(BeTrue())
Expect(IsSupportedFormat("test.m4a")).To(BeTrue())
Expect(IsSupportedFormat("test.M4A")).To(BeTrue())
Expect(IsSupportedFormat("test.mp4")).To(BeTrue())
Expect(IsSupportedFormat("test.ogg")).To(BeTrue())
Expect(IsSupportedFormat("test.OGG")).To(BeTrue())
})
It("returns false for unsupported formats", func() {
Expect(IsSupportedFormat("test.xyz")).To(BeFalse())
Expect(IsSupportedFormat("test.abc")).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())
})
})
})

269
tagwriter/wav.go Normal file
View File

@ -0,0 +1,269 @@
package tagwriter
import (
"bytes"
"encoding/binary"
"errors"
"fmt"
"io"
"os"
)
func writeWAVTags(filePath string, tags Tags) error {
f, err := os.OpenFile(filePath, os.O_RDWR, 0)
if err != nil {
return fmt.Errorf("failed to open WAV file: %w", err)
}
defer f.Close()
riffHeader := make([]byte, 12)
if _, err := f.Read(riffHeader); err != nil {
return fmt.Errorf("failed to read RIFF header: %w", err)
}
if !bytes.Equal(riffHeader[:4], []byte("RIFF")) {
return errors.New("invalid WAV file: missing RIFF header")
}
if !bytes.Equal(riffHeader[8:12], []byte("WAVE")[:4]) {
return fmt.Errorf("invalid WAV file: expected WAVE format, found %q", string(riffHeader[8:12]))
}
chunks, err := parseRIFFChunks(f)
if err != nil {
return fmt.Errorf("failed to parse RIFF chunks: %w", err)
}
id3Chunk := findOrCreateID3Chunk(chunks)
id3Data, err := encodeID3v2Tags(tags)
if err != nil {
return fmt.Errorf("failed to encode ID3v2 tags: %w", err)
}
if len(id3Data) == 0 {
return nil
}
if id3Chunk != nil {
chunkEnd := id3Chunk.Offset + 8 + int64(id3Chunk.Size)
if id3Chunk.Size%2 != 0 {
chunkEnd++
}
if err := f.Truncate(chunkEnd); err != nil {
return fmt.Errorf("failed to truncate file: %w", err)
}
}
if _, err := f.Seek(0, io.SeekEnd); err != nil {
return fmt.Errorf("failed to seek to end: %w", err)
}
if err := writeRIFFChunk(f, []byte("id3 "), id3Data); err != nil {
return fmt.Errorf("failed to write id3 chunk: %w", err)
}
if err := updateRIFFSize(f); err != nil {
return fmt.Errorf("failed to update RIFF size: %w", err)
}
return nil
}
type riffChunk struct {
ID [4]byte
Size uint32
Offset int64
}
func parseRIFFChunks(f *os.File) ([]riffChunk, error) {
var chunks []riffChunk
offset := int64(12)
for {
chunkHeader := make([]byte, 8)
n, err := f.ReadAt(chunkHeader, offset)
if err != nil {
if errors.Is(err, io.EOF) {
break
}
return nil, err
}
if n < 8 {
break
}
var chunk riffChunk
copy(chunk.ID[:], chunkHeader[:4])
chunk.Size = binary.LittleEndian.Uint32(chunkHeader[4:8])
chunk.Offset = offset
chunks = append(chunks, chunk)
padding := chunk.Size
if padding%2 != 0 {
padding++
}
offset += 8 + int64(padding)
}
return chunks, nil
}
func findOrCreateID3Chunk(chunks []riffChunk) *riffChunk {
for i := range chunks {
if bytes.Equal(chunks[i].ID[:], []byte("id3 ")) {
return &chunks[i]
}
}
return nil
}
func encodeID3v2Tags(tags Tags) ([]byte, error) {
frames := bytes.NewBuffer(nil)
if title, ok := tags[TagTitle]; ok && title != "" {
frames.Write(createTextFrame("TIT2", title))
}
if artist, ok := tags[TagArtist]; ok && artist != "" {
frames.Write(createTextFrame("TPE1", artist))
}
if album, ok := tags[TagAlbum]; ok && album != "" {
frames.Write(createTextFrame("TALB", album))
}
if albumArtist, ok := tags[TagAlbumArtist]; ok && albumArtist != "" {
frames.Write(createTextFrame("TPE2", albumArtist))
}
if year, ok := tags[TagYear]; ok && year != "" {
frames.Write(createTextFrame("TYER", year))
}
if genre, ok := tags[TagGenre]; ok && genre != "" {
frames.Write(createTextFrame("TCON", genre))
}
if trackNum, ok := tags[TagTrackNumber]; ok && trackNum != "" {
trackTotal, _ := tags[TagTrackTotal]
trackFrame := fmt.Sprintf("%s/%s", trackNum, trackTotal)
frames.Write(createTextFrame("TRCK", trackFrame))
}
if discNum, ok := tags[TagDiscNumber]; ok && discNum != "" {
discTotal, _ := tags[TagDiscTotal]
discFrame := fmt.Sprintf("%s/%s", discNum, discTotal)
frames.Write(createTextFrame("TPOS", discFrame))
}
if comment, ok := tags[TagComment]; ok && comment != "" {
frames.Write(createCommentFrame(comment))
}
if frames.Len() == 0 {
return nil, nil
}
tagSize := syncUint32(uint32(frames.Len()))
header := make([]byte, 10)
copy(header[0:3], []byte("ID3"))
header[3] = 0x03
header[4] = 0x00
header[5] = 0x00
copy(header[6:10], tagSize)
result := bytes.NewBuffer(header)
result.Write(frames.Bytes())
return result.Bytes(), nil
}
func createTextFrame(frameID string, text string) []byte {
textData := append([]byte{0x03}, []byte(text)...)
frame := make([]byte, 10)
copy(frame[0:4], []byte(frameID))
binary.BigEndian.PutUint32(frame[4:8], uint32(len(textData)))
frame[8] = 0x00
frame[9] = 0x00
return append(frame, textData...)
}
func createCommentFrame(text string) []byte {
frameData := new(bytes.Buffer)
frameData.WriteByte(0x03)
frameData.WriteString("eng")
frameData.WriteByte(0x00)
frameData.WriteString("")
frameData.WriteByte(0x00)
frameData.WriteString(text)
dataLen := frameData.Len()
frame := make([]byte, 10)
copy(frame[0:4], []byte("COMM"))
binary.BigEndian.PutUint32(frame[4:8], uint32(dataLen))
frame[8] = 0x00
frame[9] = 0x00
return append(frame, frameData.Bytes()...)
}
func syncUint32(n uint32) []byte {
result := make([]byte, 4)
result[0] = byte((n >> 21) & 0x7F)
result[1] = byte((n >> 14) & 0x7F)
result[2] = byte((n >> 7) & 0x7F)
result[3] = byte(n & 0x7F)
return result
}
func writeRIFFChunk(f *os.File, id []byte, data []byte) error {
chunk := make([]byte, 8)
copy(chunk[:4], id)
binary.LittleEndian.PutUint32(chunk[4:8], uint32(len(data)))
if _, err := f.Write(chunk); err != nil {
return err
}
if _, err := f.Write(data); err != nil {
return err
}
if len(data)%2 != 0 {
if _, err := f.Write([]byte{0}); err != nil {
return err
}
}
return nil
}
func updateRIFFSize(f *os.File) error {
fileSize, err := f.Seek(0, io.SeekEnd)
if err != nil {
return err
}
riffSize := uint32(fileSize - 8)
if riffSize%2 != 0 {
riffSize++
}
sizeBytes := make([]byte, 4)
binary.LittleEndian.PutUint32(sizeBytes, riffSize)
if _, err := f.WriteAt(sizeBytes, 4); err != nil {
return err
}
return nil
}
func init() {
_ = os.Stdin
}

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>

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