diff --git a/conf/configuration.go b/conf/configuration.go index 1c4829d82..5d00292d4 100644 --- a/conf/configuration.go +++ b/conf/configuration.go @@ -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) diff --git a/go.mod b/go.mod index a4c0c014b..77199db81 100644 --- a/go.mod +++ b/go.mod @@ -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 diff --git a/go.sum b/go.sum index 3e665ba02..d489d4bbf 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/server/nativeapi/native_api.go b/server/nativeapi/native_api.go index 669c4d7b5..89c792c1c 100644 --- a/server/nativeapi/native_api.go +++ b/server/nativeapi/native_api.go @@ -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) diff --git a/server/nativeapi/song_update.go b/server/nativeapi/song_update.go new file mode 100644 index 000000000..7ce044efd --- /dev/null +++ b/server/nativeapi/song_update.go @@ -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 + `"}`)) + } +} \ No newline at end of file diff --git a/server/serve_index.go b/server/serve_index.go index 13fa4a9ce..0be9bbcdf 100644 --- a/server/serve_index.go +++ b/server/serve_index.go @@ -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) diff --git a/tagwriter/flac.go b/tagwriter/flac.go new file mode 100644 index 000000000..a339fab65 --- /dev/null +++ b/tagwriter/flac.go @@ -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 +} \ No newline at end of file diff --git a/tagwriter/lock.go b/tagwriter/lock.go new file mode 100644 index 000000000..a305be060 --- /dev/null +++ b/tagwriter/lock.go @@ -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) +} \ No newline at end of file diff --git a/tagwriter/mp3.go b/tagwriter/mp3.go new file mode 100644 index 000000000..2b18ff48f --- /dev/null +++ b/tagwriter/mp3.go @@ -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 +} \ No newline at end of file diff --git a/tagwriter/tagwriter.go b/tagwriter/tagwriter.go new file mode 100644 index 000000000..ec944f7ad --- /dev/null +++ b/tagwriter/tagwriter.go @@ -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 +} \ No newline at end of file diff --git a/tagwriter/tagwriter_suite_test.go b/tagwriter/tagwriter_suite_test.go new file mode 100644 index 000000000..c6a689d0e --- /dev/null +++ b/tagwriter/tagwriter_suite_test.go @@ -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") +} \ No newline at end of file diff --git a/tagwriter/tagwriter_test.go b/tagwriter/tagwriter_test.go new file mode 100644 index 000000000..1faea5c10 --- /dev/null +++ b/tagwriter/tagwriter_test.go @@ -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()) + }) + }) +}) \ No newline at end of file diff --git a/ui/src/common/SongInfo.jsx b/ui/src/common/SongInfo.jsx index 1b1a014f1..48cf70b88 100644 --- a/ui/src/common/SongInfo.jsx +++ b/ui/src/common/SongInfo.jsx @@ -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: , - libraryName: , - album: ( - - ), - discSubtitle: , - albumArtist: ( - - ), - artist: ( - - ), - genre: ( - r.genres?.map((g) => g.name).join(' • ')} /> - ), - compilation: , - bitRate: , - bitDepth: , - sampleRate: , - channels: , - size: , - updatedAt: , - playCount: , - bpm: , - 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 ( + + ) + } + if (READONLY_FIELDS.includes(key)) { + const readOnlyFields = { + path: , + libraryName: , + discSubtitle: , + bitRate: , + bitDepth: , + sampleRate: , + channels: , + size: , + updatedAt: , + playCount: , + bpm: , + comment: , + compilation: , + } + return readOnlyFields[key] || null + } + return null } - roles.push([name, record.participants[name].length]) + + const viewFields = { + title: formData.title || , + libraryName: , + album: formData.album || , + discSubtitle: , + albumArtist: formData.albumArtist || , + artist: formData.artist || , + genre: formData.genre || r.genres?.map((g) => g.name).join(' • ')} />, + compilation: , + bitRate: , + bitDepth: , + sampleRate: , + channels: , + size: , + updatedAt: , + playCount: , + bpm: , + comment: , + year: formData.year ? parseInt(formData.year, 10) : , + trackNumber: formData.trackNumber ? parseInt(formData.trackNumber, 10) : , + } + 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 = + + if (editMode && record.playCount > 0) { + if (!fieldsToShow.includes('playDate')) { + fieldsToShow.push('playDate') + } } - if (config.enableReplayGain) { - data.albumGain = ( - - ) - data.trackGain = ( - - ) + 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 ( - {record.rawTags && ( + {showTabs && ( setTab(value)}> { /> )} - - {record.rawTags && ( - )} ) -} +} \ No newline at end of file diff --git a/ui/src/config.js b/ui/src/config.js index 39f0cd467..6922acd76 100644 --- a/ui/src/config.js +++ b/ui/src/config.js @@ -43,6 +43,7 @@ const defaultConfig = { separator: '/', enableInspect: true, pluginsEnabled: true, + enableTagEditing: false, } let config diff --git a/ui/src/i18n/en.json b/ui/src/i18n/en.json index 74fb23ab9..cfedc91a5 100644 --- a/ui/src/i18n/en.json +++ b/ui/src/i18n/en.json @@ -52,6 +52,9 @@ "playNext": "Play Next", "info": "Get Info", "instantMix": "Instant Mix" + }, + "notifications": { + "updated": "Song updated" } }, "album": { diff --git a/ui/src/song/SongEditButton.jsx b/ui/src/song/SongEditButton.jsx new file mode 100644 index 000000000..ba85b4c30 --- /dev/null +++ b/ui/src/song/SongEditButton.jsx @@ -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 ( + + + + + + ) +} + +export default SongEditButton \ No newline at end of file diff --git a/ui/src/song/SongEditor.jsx b/ui/src/song/SongEditor.jsx new file mode 100644 index 000000000..ab2895898 --- /dev/null +++ b/ui/src/song/SongEditor.jsx @@ -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 ( + + + {translate('resources.song.actions.edit', { _: 'Edit Song' })} + + + {loading ? ( + + ) : ( + <> + + + + + + + + )} + + + + + + + ) +} + +export default SongEditor \ No newline at end of file diff --git a/ui/src/song/SongEditorContext.jsx b/ui/src/song/SongEditorContext.jsx new file mode 100644 index 000000000..7f09315f3 --- /dev/null +++ b/ui/src/song/SongEditorContext.jsx @@ -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 ( + + {children} + + ) +} \ No newline at end of file diff --git a/ui/src/song/SongEditorDialog.jsx b/ui/src/song/SongEditorDialog.jsx new file mode 100644 index 000000000..4a88a6b68 --- /dev/null +++ b/ui/src/song/SongEditorDialog.jsx @@ -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 ( + + Edit Song + + {initialLoading ? ( + + ) : ( + <> + setFormData({ ...formData, title: e.target.value })} + fullWidth + variant="outlined" + label="Title" + margin="normal" + /> + setFormData({ ...formData, artist: e.target.value })} + fullWidth + variant="outlined" + label="Artist" + margin="normal" + /> + setFormData({ ...formData, album: e.target.value })} + fullWidth + variant="outlined" + label="Album" + margin="normal" + /> + setFormData({ ...formData, year: e.target.value })} + fullWidth + variant="outlined" + label="Year" + margin="normal" + type="number" + /> + setFormData({ ...formData, genre: e.target.value })} + fullWidth + variant="outlined" + label="Genre" + margin="normal" + /> + setFormData({ ...formData, trackNumber: e.target.value })} + fullWidth + variant="outlined" + label="Track #" + margin="normal" + type="number" + /> + + )} + + + + + + + ) +} + +export default SongEditorDialog \ No newline at end of file