From 874eb6c723f01fdeb2cf16e8a9cf0c8f05a8b6d0 Mon Sep 17 00:00:00 2001 From: "serik.perez" Date: Sat, 2 May 2026 12:25:09 +0200 Subject: [PATCH 1/3] feat(api,ui): implement track metadata editing and physical file writing Added the ability for administrators to edit song metadata directly from the "Get Info" dialog. This feature is opt-in and must be enabled via the ND_ENABLETAGEDITING configuration flag. Backend changes: - Created 'tagwriter' package to handle safe metadata writing for MP3 and FLAC. - Implemented file locking using 'unix.Flock' to prevent race conditions during writes and background scans. - Added 'PUT /api/song/{id}' endpoint to process metadata updates and synchronize changes with the SQLite database. - Forced UTF-8 encoding for ID3v2 tags to prevent "rune not supported" panics. - Included automatic 'os.Chtimes' updates to notify the background watcher of filesystem changes. Frontend changes: - Refactored the "Get Info" dialog to include an "Edit Mode" toggle. - Integrated interactive Material-UI TextField inputs for editable metadata. - Implemented optimistic UI updates to ensure the dialog reflects saved changes immediately without stale data. - Added support for 'X-ND-Authorization' headers in the edit API call using React-Admin's fetchUtils. --- conf/configuration.go | 2 + go.mod | 2 + go.sum | 6 + server/nativeapi/native_api.go | 2 +- server/nativeapi/song_update.go | 174 ++++++++++++ server/serve_index.go | 1 + tagwriter/flac.go | 99 +++++++ tagwriter/lock.go | 106 +++++++ tagwriter/mp3.go | 68 +++++ tagwriter/tagwriter.go | 130 +++++++++ tagwriter/tagwriter_suite_test.go | 17 ++ tagwriter/tagwriter_test.go | 145 ++++++++++ ui/src/common/SongInfo.jsx | 446 ++++++++++++++++++++++++------ ui/src/config.js | 1 + ui/src/i18n/en.json | 3 + ui/src/song/SongEditButton.jsx | 25 ++ ui/src/song/SongEditor.jsx | 193 +++++++++++++ ui/src/song/SongEditorContext.jsx | 37 +++ ui/src/song/SongEditorDialog.jsx | 170 ++++++++++++ 19 files changed, 1534 insertions(+), 93 deletions(-) create mode 100644 server/nativeapi/song_update.go create mode 100644 tagwriter/flac.go create mode 100644 tagwriter/lock.go create mode 100644 tagwriter/mp3.go create mode 100644 tagwriter/tagwriter.go create mode 100644 tagwriter/tagwriter_suite_test.go create mode 100644 tagwriter/tagwriter_test.go create mode 100644 ui/src/song/SongEditButton.jsx create mode 100644 ui/src/song/SongEditor.jsx create mode 100644 ui/src/song/SongEditorContext.jsx create mode 100644 ui/src/song/SongEditorDialog.jsx 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 From e2b466ecb2e4baceb68ea8e33feaf1f50b35a520 Mon Sep 17 00:00:00 2001 From: "serik.perez" Date: Sat, 2 May 2026 15:06:44 +0200 Subject: [PATCH 2/3] feat(api,ui): implement album metadata batch editing and physical file writing Added the ability for administrators to edit album-wide metadata directly from the album's "Get Info" dialog. When saved, the changes are applied recursively to every track associated with the album. --- server/nativeapi/album_update.go | 228 ++++++++++++++++++++++ server/nativeapi/native_api.go | 2 +- ui/src/album/AlbumInfo.jsx | 311 +++++++++++++++++++++++-------- 3 files changed, 466 insertions(+), 75 deletions(-) create mode 100644 server/nativeapi/album_update.go diff --git a/server/nativeapi/album_update.go b/server/nativeapi/album_update.go new file mode 100644 index 000000000..f30383576 --- /dev/null +++ b/server/nativeapi/album_update.go @@ -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) + `}`)) + } +} \ No newline at end of file diff --git a/server/nativeapi/native_api.go b/server/nativeapi/native_api.go index 89c792c1c..6a003a430 100644 --- a/server/nativeapi/native_api.go +++ b/server/nativeapi/native_api.go @@ -66,7 +66,7 @@ func (api *Router) routes() http.Handler { r.Use(server.UpdateLastAccessMiddleware(api.ds)) api.RX(r, "/user", api.users.NewRepository, true) api.addSongRoute(r) - api.R(r, "/album", model.Album{}, false) + api.addAlbumRoute(r) api.addArtistRoute(r) api.R(r, "/genre", model.Genre{}, false) api.R(r, "/player", model.Player{}, true) diff --git a/ui/src/album/AlbumInfo.jsx b/ui/src/album/AlbumInfo.jsx index 075841e43..b02d0fcec 100644 --- a/ui/src/album/AlbumInfo.jsx +++ b/ui/src/album/AlbumInfo.jsx @@ -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: , - libraryName: , - albumArtist: ( - - ), - genre: ( - - - - - - ), - date: - record?.maxYear && record.maxYear === record.minYear ? ( - - ) : ( - - ), - originalDate: - record?.maxOriginalYear && - record.maxOriginalYear === record.minOriginalYear ? ( - - ) : ( - - ), - releaseDate: , - recordLabel: ( - record.tags?.recordlabel?.join(', ')} - /> - ), - catalogNum: , - releaseType: ( - record.tags?.releasetype?.join(', ')} - /> - ), - media: ( - record.tags?.media?.join(', ')} - /> - ), - grouping: ( - record.tags?.grouping?.join(', ')} - /> - ), - mood: ( - record.tags?.mood?.join(', ')} - /> - ), - compilation: , - updatedAt: , - 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 ( + + ) + } + return null + } + + const viewFields = { + name: formData.name || , + libraryName: , + albumArtist: formData.albumArtist || ( + + ), + genre: formData.genre || ( + + + + + + ), + date: + record?.maxYear && record.maxYear === record.minYear ? ( + formData.year ? parseInt(formData.year, 10) : + ) : ( + + ), + originalDate: + record?.maxOriginalYear && + record.maxOriginalYear === record.minOriginalYear ? ( + + ) : ( + + ), + releaseDate: , + recordLabel: ( + record.tags?.recordlabel?.join(', ')} + /> + ), + catalogNum: , + releaseType: ( + record.tags?.releasetype?.join(', ')} + /> + ), + media: ( + record.tags?.media?.join(', ')} + /> + ), + grouping: ( + record.tags?.grouping?.join(', ')} + /> + ), + mood: ( + record.tags?.mood?.join(', ')} + /> + ), + compilation: , + updatedAt: , + 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 ( +
+ {config.enableTagEditing && !isEditing && ( + + )} + {isEditing && ( +
+ + +
+ )} +
- {Object.keys(data).map((key) => { + {fieldsToShow.map((key) => { + const cellContent = buildField(key) + if (!cellContent) return null return ( { : - {data[key]} + {cellContent} ) })} - + {!isEditing && }
From 1dd5f4e781415759231b7067778df5f9f74ca0e9 Mon Sep 17 00:00:00 2001 From: "serik.perez" Date: Sat, 2 May 2026 15:16:23 +0200 Subject: [PATCH 3/3] feat(api): expand tagwriter support for WAV, M4A, and Ogg Vorbis Extended the 'tagwriter' package to support a wider range of audio formats, ensuring comprehensive metadata management across the most common lossless and lossy types. --- tagwriter/m4a.go | 426 ++++++++++++++++++++++++++++++++++++ tagwriter/ogg.go | 396 +++++++++++++++++++++++++++++++++ tagwriter/tagwriter.go | 8 +- tagwriter/tagwriter_test.go | 17 +- tagwriter/wav.go | 269 +++++++++++++++++++++++ 5 files changed, 1110 insertions(+), 6 deletions(-) create mode 100644 tagwriter/m4a.go create mode 100644 tagwriter/ogg.go create mode 100644 tagwriter/wav.go diff --git a/tagwriter/m4a.go b/tagwriter/m4a.go new file mode 100644 index 000000000..4c8764b73 --- /dev/null +++ b/tagwriter/m4a.go @@ -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 +} \ No newline at end of file diff --git a/tagwriter/ogg.go b/tagwriter/ogg.go new file mode 100644 index 000000000..38c59937f --- /dev/null +++ b/tagwriter/ogg.go @@ -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 +} \ No newline at end of file diff --git a/tagwriter/tagwriter.go b/tagwriter/tagwriter.go index ec944f7ad..bd852b5cf 100644 --- a/tagwriter/tagwriter.go +++ b/tagwriter/tagwriter.go @@ -82,6 +82,12 @@ func (t *tagWriter) WriteTags(filePath string, tags Tags) error { 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 } @@ -116,7 +122,7 @@ func (t *tagWriter) checkFilePermissions(filePath string) error { } func SupportedFormats() []string { - return []string{".mp3", ".mp2", ".flac"} + return []string{".mp3", ".mp2", ".flac", ".wav", ".wave", ".m4a", ".mp4", ".ogg"} } func IsSupportedFormat(filePath string) bool { diff --git a/tagwriter/tagwriter_test.go b/tagwriter/tagwriter_test.go index 1faea5c10..81cde7b4d 100644 --- a/tagwriter/tagwriter_test.go +++ b/tagwriter/tagwriter_test.go @@ -35,7 +35,7 @@ var _ = Describe("TagWriter", func() { }) It("returns error for unsupported formats", func() { - testFile := filepath.Join(testDir, "test.ogg") + testFile := filepath.Join(testDir, "test.xyz") f, err := os.Create(testFile) Expect(err).NotTo(HaveOccurred()) f.Close() @@ -87,17 +87,24 @@ var _ = Describe("TagWriter", func() { }) Describe("IsSupportedFormat", func() { - It("returns true for supported formats", 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.ogg")).To(BeFalse()) - Expect(IsSupportedFormat("test.wav")).To(BeFalse()) - Expect(IsSupportedFormat("test.m4a")).To(BeFalse()) + Expect(IsSupportedFormat("test.xyz")).To(BeFalse()) + Expect(IsSupportedFormat("test.abc")).To(BeFalse()) }) }) diff --git a/tagwriter/wav.go b/tagwriter/wav.go new file mode 100644 index 000000000..3f5b2d475 --- /dev/null +++ b/tagwriter/wav.go @@ -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 +} \ No newline at end of file