mirror of
https://github.com/navidrome/navidrome.git
synced 2026-08-31 07:30:32 +00:00
Merge 1dd5f4e781415759231b7067778df5f9f74ca0e9 into 838ceee26d6f95dc998b6810d2008688b899c761
This commit is contained in:
commit
80bbcf6b3d
@ -83,6 +83,7 @@ type configOptions struct {
|
||||
EnableStarRating bool
|
||||
EnableUserEditing bool
|
||||
EnableArtworkUpload bool
|
||||
EnableTagEditing bool
|
||||
MaxImageUploadSize string
|
||||
EnableSharing bool
|
||||
ShareURL string
|
||||
@ -781,6 +782,7 @@ func setViperDefaults() {
|
||||
viper.SetDefault("enablefavourites", true)
|
||||
viper.SetDefault("enablestarrating", true)
|
||||
viper.SetDefault("enableuserediting", true)
|
||||
viper.SetDefault("enabletagediting", false)
|
||||
viper.SetDefault("defaulttheme", "Dark")
|
||||
viper.SetDefault("defaultlanguage", "")
|
||||
viper.SetDefault("defaultuivolume", consts.DefaultUIVolume)
|
||||
|
||||
2
go.mod
2
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
|
||||
|
||||
6
go.sum
6
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-20260511232939-ccd334abae3a h1:L5E3uF4hKLEqoEYT0tXXuFH6c3PEEzQSWLfTqF5Lpqw=
|
||||
@ -81,6 +84,8 @@ github.com/go-chi/httprate v0.15.0 h1:j54xcWV9KGmPf/X4H32/aTH+wBlrvxL7P+SdnRqxh5
|
||||
github.com/go-chi/httprate v0.15.0/go.mod h1:rzGHhVrsBn3IMLYDOZQsSU4fJNWcjui4fWKJcCId1R4=
|
||||
github.com/go-chi/jwtauth/v5 v5.4.0 h1:Ieh0xMJsFvqylqJ02/mQHKzbbKO9DYNBh4DPKCwTwYI=
|
||||
github.com/go-chi/jwtauth/v5 v5.4.0/go.mod h1:w6yjqUUXz1b8+oiJel64Sz1KJwduQM6qUA5QNzO5+bQ=
|
||||
github.com/go-flac/go-flac v0.3.1 h1:BWA7HdO67S4ZLWSVHCxsDHuedFFu5RiV/wmuhvO6Hxo=
|
||||
github.com/go-flac/go-flac v0.3.1/go.mod h1:jG9IumOfAXr+7J40x0AiQIbJzXf9Y7+Zs/2CNWe4LMk=
|
||||
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
github.com/go-sql-driver/mysql v1.4.1/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w=
|
||||
@ -101,6 +106,7 @@ github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7Lk
|
||||
github.com/gohugoio/hashstructure v0.6.0 h1:7wMB/2CfXoThFYhdWRGv3u3rUM761Cq29CxUW+NltUg=
|
||||
github.com/gohugoio/hashstructure v0.6.0/go.mod h1:lapVLk9XidheHG1IQ4ZSbyYrXcaILU1ZEP/+vno5rBQ=
|
||||
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
|
||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
|
||||
228
server/nativeapi/album_update.go
Normal file
228
server/nativeapi/album_update.go
Normal file
@ -0,0 +1,228 @@
|
||||
package nativeapi
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/deluan/rest"
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/server"
|
||||
"github.com/navidrome/navidrome/tagwriter"
|
||||
"github.com/Masterminds/squirrel"
|
||||
)
|
||||
|
||||
type AlbumUpdateRequest struct {
|
||||
Album string `json:"album"`
|
||||
Name string `json:"name"`
|
||||
AlbumArtist string `json:"albumArtist"`
|
||||
Year *int `json:"year"`
|
||||
Genre string `json:"genre"`
|
||||
Comment string `json:"comment"`
|
||||
}
|
||||
|
||||
func (api *Router) addAlbumRoute(r chi.Router) {
|
||||
albumConstructor := func(ctx context.Context) rest.Repository {
|
||||
return api.ds.Resource(ctx, model.Album{})
|
||||
}
|
||||
|
||||
r.Route("/album", func(r chi.Router) {
|
||||
r.Get("/", rest.GetAll(albumConstructor))
|
||||
|
||||
r.Route("/{id}", func(r chi.Router) {
|
||||
r.Use(server.URLParamsMiddleware)
|
||||
r.Get("/", rest.Get(albumConstructor))
|
||||
r.Put("/", api.updateAlbum())
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func (api *Router) updateAlbum() http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
|
||||
bodyBytesDebug, _ := io.ReadAll(r.Body)
|
||||
log.Info(r.Context(), "DEBUG: Raw JSON Received", "json", string(bodyBytesDebug))
|
||||
r.Body = io.NopCloser(bytes.NewBuffer(bodyBytesDebug))
|
||||
|
||||
if !conf.Server.EnableTagEditing {
|
||||
log.Warn(r.Context(), "Tag editing attempt while disabled")
|
||||
http.Error(w, "Tag editing is disabled in configuration", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
albumID := chi.URLParamFromCtx(ctx, "id")
|
||||
if albumID == "" {
|
||||
log.Warn(r.Context(), "Album ID missing in update request")
|
||||
http.Error(w, "Album ID is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
log.Debug(r.Context(), "Fetching Album", "id", albumID)
|
||||
album, err := api.ds.Album(ctx).Get(albumID)
|
||||
if err != nil {
|
||||
if errors.Is(err, model.ErrNotFound) {
|
||||
log.Warn(r.Context(), "Album not found", "id", albumID)
|
||||
http.Error(w, "Album not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
log.Error(r.Context(), "Failed to retrieve album", "error", err, "id", albumID)
|
||||
http.Error(w, "Failed to retrieve album", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
log.Debug(r.Context(), "Album retrieved", "album_id", album.ID, "name", album.Name, "song_count", album.SongCount)
|
||||
|
||||
log.Debug(r.Context(), "Fetching MediaFiles for album", "albumId", albumID)
|
||||
mediaFiles, err := api.ds.MediaFile(ctx).GetAll(model.QueryOptions{Filters: squirrel.Eq{"album_id": albumID}})
|
||||
if err != nil {
|
||||
log.Error(r.Context(), "Failed to retrieve media files for album", "error", err, "albumId", albumID)
|
||||
http.Error(w, "Failed to retrieve media files", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
log.Info(r.Context(), "Batch update starting", "album_id", albumID, "count", len(mediaFiles))
|
||||
|
||||
if len(mediaFiles) == 0 {
|
||||
log.Warn(r.Context(), "No media files found for album", "albumId", albumID)
|
||||
http.Error(w, "No media files found for this album", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
log.Debug(r.Context(), "Parsing request body", "albumId", albumID)
|
||||
|
||||
bodyBytes, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
log.Error(r.Context(), "Failed to read request body", "error", err)
|
||||
http.Error(w, "Failed to read request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
log.Debug(r.Context(), "Raw request body", "body", string(bodyBytes))
|
||||
|
||||
var req AlbumUpdateRequest
|
||||
if err := json.Unmarshal(bodyBytes, &req); err != nil {
|
||||
log.Error(r.Context(), "Failed to decode JSON payload", "error", err)
|
||||
http.Error(w, "Invalid JSON payload", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
log.Debug(r.Context(), "Request body parsed", "album", req.Album, "albumArtist", req.AlbumArtist, "year", req.Year)
|
||||
|
||||
newAlbumName := req.Album
|
||||
newArtist := req.AlbumArtist
|
||||
newYear := req.Year
|
||||
newGenre := req.Genre
|
||||
newComment := req.Comment
|
||||
|
||||
log.Debug(r.Context(), "Local variables assigned", "newAlbumName", newAlbumName, "newArtist", newArtist)
|
||||
|
||||
titleToUse := newAlbumName
|
||||
if titleToUse == "" {
|
||||
titleToUse = req.Name
|
||||
}
|
||||
log.Info(r.Context(), "DEBUG: Title to be used for tracks", "title", titleToUse)
|
||||
|
||||
tw := tagwriter.New()
|
||||
updatedCount := 0
|
||||
failedCount := 0
|
||||
|
||||
for _, mf := range mediaFiles {
|
||||
absPath := mf.AbsolutePath()
|
||||
|
||||
log.Info(r.Context(), "Processing track", "mediaFileId", mf.ID, "path", absPath, "newAlbum", titleToUse)
|
||||
|
||||
tags := make(tagwriter.Tags)
|
||||
tags[tagwriter.TagAlbum] = titleToUse
|
||||
tags[tagwriter.TagAlbumArtist] = newArtist
|
||||
if newYear != nil && *newYear > 0 {
|
||||
tags[tagwriter.TagYear] = strconv.Itoa(*newYear)
|
||||
}
|
||||
if newGenre != "" {
|
||||
tags[tagwriter.TagGenre] = newGenre
|
||||
}
|
||||
if newComment != "" {
|
||||
tags[tagwriter.TagComment] = newComment
|
||||
}
|
||||
|
||||
if err := tw.WriteTags(absPath, tags); err != nil {
|
||||
if errors.Is(err, tagwriter.ErrFeatureDisabled) {
|
||||
log.Warn(r.Context(), "Tag writing disabled in config", "error", err)
|
||||
http.Error(w, "Tag editing is disabled in configuration", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
if errors.Is(err, tagwriter.ErrUnsupportedFormat) {
|
||||
log.Warn(r.Context(), "Unsupported file format", "error", err, "path", absPath)
|
||||
http.Error(w, "Unsupported file format", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if errors.Is(err, tagwriter.ErrReadOnlyFile) {
|
||||
log.Warn(r.Context(), "File is read-only", "error", err, "path", absPath)
|
||||
http.Error(w, "File is read-only", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
log.Error(r.Context(), "Failed to write tags to file", "error", err, "path", absPath, "mediaFileId", mf.ID)
|
||||
failedCount++
|
||||
continue
|
||||
}
|
||||
|
||||
if err := os.Chtimes(absPath, time.Now(), time.Now()); err != nil {
|
||||
log.Error(r.Context(), "Failed to update file modification time", "error", err, "path", absPath)
|
||||
}
|
||||
|
||||
mf.Album = titleToUse
|
||||
mf.AlbumArtist = newArtist
|
||||
if newYear != nil && *newYear > 0 {
|
||||
mf.Year = *newYear
|
||||
}
|
||||
mf.Genre = newGenre
|
||||
mf.Comment = newComment
|
||||
|
||||
log.Debug(r.Context(), "Updating MediaFile record", "mediaFileId", mf.ID, "album", mf.Album, "albumArtist", mf.AlbumArtist)
|
||||
if err := api.ds.MediaFile(ctx).Put(&mf); err != nil {
|
||||
log.Error(r.Context(), "Failed to update MediaFile in database", "error", err, "mediaFileId", mf.ID)
|
||||
failedCount++
|
||||
continue
|
||||
}
|
||||
|
||||
updatedCount++
|
||||
log.Debug(r.Context(), "Successfully updated media file", "mediaFileId", mf.ID)
|
||||
}
|
||||
|
||||
if req.Album != "" {
|
||||
album.Name = req.Album
|
||||
}
|
||||
if req.AlbumArtist != "" {
|
||||
album.AlbumArtist = req.AlbumArtist
|
||||
}
|
||||
if req.Year != nil && *req.Year > 0 {
|
||||
album.MaxYear = *req.Year
|
||||
}
|
||||
if req.Genre != "" {
|
||||
album.Genre = req.Genre
|
||||
}
|
||||
if req.Comment != "" {
|
||||
album.Comment = req.Comment
|
||||
}
|
||||
|
||||
if err := api.ds.Album(ctx).Put(album); err != nil {
|
||||
log.Error(r.Context(), "Failed to update Album in database", "error", err, "albumId", albumID)
|
||||
http.Error(w, "Failed to update album", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
log.Info(r.Context(), "Album batch update completed", "albumId", albumID, "updated", updatedCount, "failed", failedCount)
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(`{"id":"` + albumID + `", "name":"` + album.Name + `", "updated":` + strconv.Itoa(updatedCount) + `, "failed":` + strconv.Itoa(failedCount) + `}`))
|
||||
}
|
||||
}
|
||||
@ -65,8 +65,8 @@ func (api *Router) routes() http.Handler {
|
||||
r.Use(server.JWTRefresher)
|
||||
r.Use(server.UpdateLastAccessMiddleware(api.ds))
|
||||
api.RX(r, "/user", api.users.NewRepository, true)
|
||||
api.R(r, "/song", model.MediaFile{}, false)
|
||||
api.R(r, "/album", model.Album{}, false)
|
||||
api.addSongRoute(r)
|
||||
api.addAlbumRoute(r)
|
||||
api.addArtistRoute(r)
|
||||
api.R(r, "/genre", model.Genre{}, false)
|
||||
api.R(r, "/player", model.Player{}, true)
|
||||
|
||||
174
server/nativeapi/song_update.go
Normal file
174
server/nativeapi/song_update.go
Normal file
@ -0,0 +1,174 @@
|
||||
package nativeapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/deluan/rest"
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/server"
|
||||
"github.com/navidrome/navidrome/tagwriter"
|
||||
)
|
||||
|
||||
type SongUpdateRequest struct {
|
||||
Title string `json:"title"`
|
||||
Artist string `json:"artist"`
|
||||
Album string `json:"album"`
|
||||
AlbumArtist string `json:"albumArtist"`
|
||||
Year *int `json:"year"`
|
||||
Genre string `json:"genre"`
|
||||
TrackNumber *int `json:"trackNumber"`
|
||||
}
|
||||
|
||||
func (api *Router) addSongRoute(r chi.Router) {
|
||||
constructor := func(ctx context.Context) rest.Repository {
|
||||
return api.ds.Resource(ctx, model.MediaFile{})
|
||||
}
|
||||
|
||||
r.Route("/song", func(r chi.Router) {
|
||||
r.Get("/", rest.GetAll(constructor))
|
||||
r.Post("/", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
w.Write([]byte(`{"error": "Method not allowed"}`))
|
||||
})
|
||||
r.Route("/{id}", func(r chi.Router) {
|
||||
r.Use(server.URLParamsMiddleware)
|
||||
r.Get("/", rest.Get(constructor))
|
||||
r.Put("/", api.updateSong())
|
||||
r.Delete("/", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
w.Write([]byte(`{"error": "Method not allowed"}`))
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func (api *Router) updateSong() http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
|
||||
if !conf.Server.EnableTagEditing {
|
||||
log.Warn(r.Context(), "Tag editing attempt while disabled")
|
||||
http.Error(w, "Tag editing is disabled in configuration", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
songID := chi.URLParamFromCtx(ctx, "id")
|
||||
if songID == "" {
|
||||
log.Warn(r.Context(), "Song ID missing in update request")
|
||||
http.Error(w, "Song ID is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
log.Debug(r.Context(), "Fetching MediaFile", "id", songID)
|
||||
mf, err := api.ds.MediaFile(ctx).Get(songID)
|
||||
if err != nil {
|
||||
if errors.Is(err, model.ErrNotFound) {
|
||||
log.Warn(r.Context(), "Song not found", "id", songID)
|
||||
http.Error(w, "Song not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
log.Error(r.Context(), "Failed to retrieve song", "error", err, "id", songID)
|
||||
http.Error(w, "Failed to retrieve song", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
log.Debug(r.Context(), "Parsing request body", "id", songID)
|
||||
var req SongUpdateRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
log.Error(r.Context(), "Failed to decode JSON payload", "error", err)
|
||||
http.Error(w, "Invalid JSON payload", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
log.Debug(r.Context(), "Writing tags to file", "path", mf.AbsolutePath(), "id", songID)
|
||||
absPath := mf.AbsolutePath()
|
||||
|
||||
tags := make(tagwriter.Tags)
|
||||
if req.Title != "" {
|
||||
tags[tagwriter.TagTitle] = req.Title
|
||||
}
|
||||
if req.Artist != "" {
|
||||
tags[tagwriter.TagArtist] = req.Artist
|
||||
}
|
||||
if req.Album != "" {
|
||||
tags[tagwriter.TagAlbum] = req.Album
|
||||
}
|
||||
if req.AlbumArtist != "" {
|
||||
tags[tagwriter.TagAlbumArtist] = req.AlbumArtist
|
||||
}
|
||||
if req.Year != nil && *req.Year > 0 {
|
||||
tags[tagwriter.TagYear] = strconv.Itoa(*req.Year)
|
||||
}
|
||||
if req.Genre != "" {
|
||||
tags[tagwriter.TagGenre] = req.Genre
|
||||
}
|
||||
if req.TrackNumber != nil && *req.TrackNumber > 0 {
|
||||
tags[tagwriter.TagTrackNumber] = strconv.Itoa(*req.TrackNumber)
|
||||
}
|
||||
|
||||
tw := tagwriter.New()
|
||||
if err := tw.WriteTags(absPath, tags); err != nil {
|
||||
if errors.Is(err, tagwriter.ErrFeatureDisabled) {
|
||||
log.Warn(r.Context(), "Tag writing disabled in config", "error", err)
|
||||
http.Error(w, "Tag editing is disabled in configuration", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
if errors.Is(err, tagwriter.ErrUnsupportedFormat) {
|
||||
log.Warn(r.Context(), "Unsupported file format", "error", err, "path", absPath)
|
||||
http.Error(w, "Unsupported file format", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if errors.Is(err, tagwriter.ErrReadOnlyFile) {
|
||||
log.Warn(r.Context(), "File is read-only", "error", err, "path", absPath)
|
||||
http.Error(w, "File is read-only", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
log.Error(r.Context(), "Failed to write tags", "error", err, "path", absPath)
|
||||
http.Error(w, "Failed to write tags: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
log.Debug(r.Context(), "Updating MediaFile in database", "id", songID)
|
||||
if req.Title != "" {
|
||||
mf.Title = req.Title
|
||||
}
|
||||
if req.Artist != "" {
|
||||
mf.Artist = req.Artist
|
||||
}
|
||||
if req.Album != "" {
|
||||
mf.Album = req.Album
|
||||
}
|
||||
if req.AlbumArtist != "" {
|
||||
mf.AlbumArtist = req.AlbumArtist
|
||||
}
|
||||
if req.Year != nil && *req.Year > 0 {
|
||||
mf.Year = *req.Year
|
||||
}
|
||||
if req.Genre != "" {
|
||||
mf.Genre = req.Genre
|
||||
}
|
||||
if req.TrackNumber != nil && *req.TrackNumber > 0 {
|
||||
mf.TrackNumber = *req.TrackNumber
|
||||
}
|
||||
|
||||
if err := api.ds.MediaFile(ctx).Put(mf); err != nil {
|
||||
log.Error(r.Context(), "Failed to update database", "error", err, "id", songID)
|
||||
http.Error(w, "Failed to update database", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
log.Info(r.Context(), "Song updated successfully", "id", songID, "title", mf.Title)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(`{"id":"` + songID + `", "title":"` + mf.Title + `"}`))
|
||||
}
|
||||
}
|
||||
@ -80,6 +80,7 @@ func serveIndex(ds model.DataStore, fs fs.FS, shareInfo *model.Share) http.Handl
|
||||
"enableInspect": conf.Server.Inspect.Enabled,
|
||||
"pluginsEnabled": conf.Server.Plugins.Enabled,
|
||||
"extAuthLogoutURL": conf.Server.ExtAuth.LogoutURL,
|
||||
"enableTagEditing": conf.Server.EnableTagEditing,
|
||||
}
|
||||
if strings.HasPrefix(conf.Server.UILoginBackgroundURL, "/") {
|
||||
appConfig["loginBackgroundURL"] = path.Join(conf.Server.BasePath, conf.Server.UILoginBackgroundURL)
|
||||
|
||||
99
tagwriter/flac.go
Normal file
99
tagwriter/flac.go
Normal file
@ -0,0 +1,99 @@
|
||||
package tagwriter
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/go-flac/go-flac"
|
||||
)
|
||||
|
||||
func writeFLACTags(filePath string, tags Tags) error {
|
||||
flacFile, err := flac.ParseFile(filePath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to parse FLAC file: %w", err)
|
||||
}
|
||||
|
||||
var vorbisCommentIndex int = -1
|
||||
for i, block := range flacFile.Meta {
|
||||
if block.Type == flac.VorbisComment {
|
||||
vorbisCommentIndex = i
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
vorbisData := encodeVorbisComments(tags)
|
||||
|
||||
if vorbisCommentIndex >= 0 {
|
||||
flacFile.Meta[vorbisCommentIndex].Data = vorbisData
|
||||
} else {
|
||||
flacFile.Meta = append(flacFile.Meta, &flac.MetaDataBlock{
|
||||
Type: flac.VorbisComment,
|
||||
Data: vorbisData,
|
||||
})
|
||||
}
|
||||
|
||||
if err := flacFile.Save(filePath); err != nil {
|
||||
return fmt.Errorf("failed to save FLAC file: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func encodeVorbisComments(tags Tags) flac.BlockData {
|
||||
buf := make([]byte, 0)
|
||||
|
||||
vendor := "Navidrome"
|
||||
vendorBytes := []byte(vendor)
|
||||
buf = append(buf, encodeUint32LE(uint32(len(vendorBytes)))...)
|
||||
buf = append(buf, vendorBytes...)
|
||||
|
||||
numComments := countNonEmptyTags(tags)
|
||||
buf = append(buf, encodeUint32LE(uint32(numComments))...)
|
||||
|
||||
commentPairs := map[string]string{
|
||||
"TITLE": TagTitle,
|
||||
"ARTIST": TagArtist,
|
||||
"ALBUM": TagAlbum,
|
||||
"ALBUMARTIST": TagAlbumArtist,
|
||||
"DATE": TagYear,
|
||||
"YEAR": TagYear,
|
||||
"GENRE": TagGenre,
|
||||
"TRACKNUMBER": TagTrackNumber,
|
||||
"TRACKTOTAL": TagTrackTotal,
|
||||
"DISCNUMBER": TagDiscNumber,
|
||||
"DISCTOTAL": TagDiscTotal,
|
||||
"COMMENT": TagComment,
|
||||
}
|
||||
|
||||
for vorbisKey, tagKey := range commentPairs {
|
||||
if value, ok := tags[tagKey]; ok && value != "" {
|
||||
comment := fmt.Sprintf("%s=%s", vorbisKey, value)
|
||||
commentBytes := []byte(comment)
|
||||
buf = append(buf, encodeUint32LE(uint32(len(commentBytes)))...)
|
||||
buf = append(buf, commentBytes...)
|
||||
}
|
||||
}
|
||||
|
||||
return buf
|
||||
}
|
||||
|
||||
func countNonEmptyTags(tags Tags) int {
|
||||
count := 0
|
||||
for _, v := range tags {
|
||||
if v != "" {
|
||||
count++
|
||||
}
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
func encodeUint32LE(n uint32) []byte {
|
||||
b := make([]byte, 4)
|
||||
binary.LittleEndian.PutUint32(b, n)
|
||||
return b
|
||||
}
|
||||
|
||||
func init() {
|
||||
_ = os.Stdin
|
||||
}
|
||||
106
tagwriter/lock.go
Normal file
106
tagwriter/lock.go
Normal file
@ -0,0 +1,106 @@
|
||||
package tagwriter
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
|
||||
var (
|
||||
lockRegistry = struct {
|
||||
mu sync.RWMutex
|
||||
files map[string]*fileLock
|
||||
}{files: make(map[string]*fileLock)}
|
||||
)
|
||||
|
||||
type fileLock struct {
|
||||
file *os.File
|
||||
ref int
|
||||
}
|
||||
|
||||
func LockFile(filePath string) (*fileLock, error) {
|
||||
absPath, err := abs(filePath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid path: %w", err)
|
||||
}
|
||||
|
||||
lockRegistry.mu.Lock()
|
||||
defer lockRegistry.mu.Unlock()
|
||||
|
||||
if existing, ok := lockRegistry.files[absPath]; ok {
|
||||
existing.ref++
|
||||
return existing, nil
|
||||
}
|
||||
|
||||
f, err := os.OpenFile(absPath, os.O_RDWR, 0)
|
||||
if err != nil {
|
||||
if os.IsPermission(err) {
|
||||
return nil, fmt.Errorf("permission denied opening file: %w", err)
|
||||
}
|
||||
return nil, fmt.Errorf("failed to open file: %w", err)
|
||||
}
|
||||
|
||||
err = unix.Flock(int(f.Fd()), unix.LOCK_EX|unix.LOCK_NB)
|
||||
if err != nil {
|
||||
f.Close()
|
||||
return nil, fmt.Errorf("failed to acquire lock: %w", err)
|
||||
}
|
||||
|
||||
lockRegistry.files[absPath] = &fileLock{file: f, ref: 1}
|
||||
return lockRegistry.files[absPath], nil
|
||||
}
|
||||
|
||||
func UnlockFile(lock *fileLock) error {
|
||||
if lock == nil || lock.file == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
lockRegistry.mu.Lock()
|
||||
defer lockRegistry.mu.Unlock()
|
||||
|
||||
absPath, err := abs(lock.file.Name())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if existing, ok := lockRegistry.files[absPath]; ok {
|
||||
existing.ref--
|
||||
if existing.ref > 0 {
|
||||
return nil
|
||||
}
|
||||
delete(lockRegistry.files, absPath)
|
||||
}
|
||||
|
||||
if err := unix.Flock(int(lock.file.Fd()), unix.LOCK_UN); err != nil {
|
||||
return fmt.Errorf("failed to release lock: %w", err)
|
||||
}
|
||||
|
||||
return lock.file.Close()
|
||||
}
|
||||
|
||||
func abs(path string) (string, error) {
|
||||
if path == "" {
|
||||
return "", fmt.Errorf("empty path")
|
||||
}
|
||||
if path[0] == '/' {
|
||||
return path, nil
|
||||
}
|
||||
absPath, err := filepath.Abs(path)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to get absolute path: %w", err)
|
||||
}
|
||||
return absPath, nil
|
||||
}
|
||||
|
||||
func ClearLocks() {
|
||||
lockRegistry.mu.Lock()
|
||||
defer lockRegistry.mu.Unlock()
|
||||
for _, fl := range lockRegistry.files {
|
||||
unix.Flock(int(fl.file.Fd()), unix.LOCK_UN)
|
||||
fl.file.Close()
|
||||
}
|
||||
lockRegistry.files = make(map[string]*fileLock)
|
||||
}
|
||||
426
tagwriter/m4a.go
Normal file
426
tagwriter/m4a.go
Normal file
@ -0,0 +1,426 @@
|
||||
package tagwriter
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"time"
|
||||
)
|
||||
|
||||
func writeM4ATags(filePath string, tags Tags) error {
|
||||
f, err := os.OpenFile(filePath, os.O_RDWR, 0)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to open M4A file: %w", err)
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
fileInfo, err := f.Stat()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to stat file: %w", err)
|
||||
}
|
||||
originalSize := fileInfo.Size()
|
||||
|
||||
atoms, err := parseMP4Atoms(f)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to parse MP4 atoms: %w", err)
|
||||
}
|
||||
|
||||
ilstAtom := findILSTAtom(atoms)
|
||||
|
||||
metadataData, err := encodeILSTMetadata(tags)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to encode metadata: %w", err)
|
||||
}
|
||||
|
||||
if len(metadataData) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
newFileSize := originalSize
|
||||
if ilstAtom != nil {
|
||||
oldILSTSize := calculateAtomSize(int(ilstAtom.DataSize))
|
||||
newILSTSize := calculateAtomSize(len(metadataData))
|
||||
delta := int64(newILSTSize) - int64(oldILSTSize)
|
||||
newFileSize = originalSize + delta
|
||||
} else {
|
||||
newFileSize = originalSize + int64(calculateAtomSize(len(metadataData))+8)
|
||||
}
|
||||
|
||||
if newFileSize > originalSize {
|
||||
if err := f.Truncate(newFileSize); err != nil {
|
||||
return fmt.Errorf("failed to extend file: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if ilstAtom != nil {
|
||||
oldSize := calculateAtomSize(int(ilstAtom.DataSize))
|
||||
newSize := calculateAtomSize(len(metadataData))
|
||||
delta := int(newSize) - int(oldSize)
|
||||
|
||||
if err := shiftDataAfter(f, ilstAtom.Offset+8+int64(oldSize), int64(delta)); err != nil {
|
||||
return fmt.Errorf("failed to shift data: %w", err)
|
||||
}
|
||||
|
||||
if err := writeILSTAtom(f, ilstAtom.Offset, metadataData); err != nil {
|
||||
return fmt.Errorf("failed to write ilst atom: %w", err)
|
||||
}
|
||||
} else {
|
||||
moovAtom := findMoovAtom(atoms)
|
||||
if moovAtom == nil {
|
||||
return errors.New("missing moov atom")
|
||||
}
|
||||
|
||||
insertionOffset := moovAtom.Offset + 8
|
||||
if err := shiftDataAfter(f, insertionOffset, int64(calculateAtomSize(len(metadataData))+8)); err != nil {
|
||||
return fmt.Errorf("failed to shift data for new atom: %w", err)
|
||||
}
|
||||
|
||||
newILSTOffset := insertionOffset
|
||||
if err := writeFullAtom(f, newILSTOffset, []byte("ilst"), metadataData); err != nil {
|
||||
return fmt.Errorf("failed to write new ilst atom: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
updateFileTimes(filePath)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
type mp4Atom struct {
|
||||
Type [4]byte
|
||||
Size uint32
|
||||
DataSize uint32
|
||||
Offset int64
|
||||
Children []mp4Atom
|
||||
}
|
||||
|
||||
func parseMP4Atoms(f *os.File) ([]mp4Atom, error) {
|
||||
var atoms []mp4Atom
|
||||
offset := int64(0)
|
||||
|
||||
for {
|
||||
header := make([]byte, 8)
|
||||
n, err := f.ReadAt(header, offset)
|
||||
if err != nil || n < 8 {
|
||||
break
|
||||
}
|
||||
|
||||
size := binary.BigEndian.Uint32(header[:4])
|
||||
atomType := [4]byte{}
|
||||
copy(atomType[:], header[4:8])
|
||||
|
||||
if size == 0 {
|
||||
break
|
||||
}
|
||||
|
||||
if size == 1 {
|
||||
extendedSize := make([]byte, 8)
|
||||
if _, err := f.ReadAt(extendedSize, offset+8); err != nil || len(extendedSize) < 8 {
|
||||
break
|
||||
}
|
||||
size = binary.BigEndian.Uint32(extendedSize[4:8])
|
||||
}
|
||||
|
||||
var dataSize uint32
|
||||
if size >= 8 {
|
||||
dataSize = size - 8
|
||||
}
|
||||
|
||||
atom := mp4Atom{
|
||||
Type: atomType,
|
||||
Size: size,
|
||||
DataSize: dataSize,
|
||||
Offset: offset,
|
||||
}
|
||||
|
||||
if isContainerAtom(atomType) {
|
||||
childOffset := offset + 8
|
||||
childEnd := offset + int64(size)
|
||||
for childOffset < childEnd {
|
||||
childHeader := make([]byte, 8)
|
||||
m, err := f.ReadAt(childHeader, childOffset)
|
||||
if err != nil || m < 8 {
|
||||
break
|
||||
}
|
||||
childSize := binary.BigEndian.Uint32(childHeader[:4])
|
||||
if childSize == 0 {
|
||||
break
|
||||
}
|
||||
childType := [4]byte{}
|
||||
copy(childType[:], childHeader[4:8])
|
||||
|
||||
if isContainerAtom(childType) {
|
||||
childAtoms, err := parseContainerAtom(f, childOffset)
|
||||
if err == nil {
|
||||
atom.Children = append(atom.Children, childAtoms...)
|
||||
}
|
||||
} else {
|
||||
childAtom := mp4Atom{
|
||||
Type: childType,
|
||||
Size: childSize,
|
||||
DataSize: childSize - 8,
|
||||
Offset: childOffset,
|
||||
}
|
||||
atom.Children = append(atom.Children, childAtom)
|
||||
}
|
||||
|
||||
childOffset += int64(childSize)
|
||||
}
|
||||
}
|
||||
|
||||
atoms = append(atoms, atom)
|
||||
offset += int64(size)
|
||||
}
|
||||
|
||||
return atoms, nil
|
||||
}
|
||||
|
||||
func parseContainerAtom(f *os.File, offset int64) ([]mp4Atom, error) {
|
||||
var atoms []mp4Atom
|
||||
|
||||
header := make([]byte, 8)
|
||||
if _, err := f.ReadAt(header, offset); err != nil || len(header) < 8 {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
parentSize := binary.BigEndian.Uint32(header[:4])
|
||||
childEnd := offset + int64(parentSize) - 8
|
||||
|
||||
childOffset := offset + 8
|
||||
for childOffset < childEnd {
|
||||
childHeader := make([]byte, 8)
|
||||
n, err := f.ReadAt(childHeader, childOffset)
|
||||
if err != nil || n < 8 {
|
||||
break
|
||||
}
|
||||
childSize := binary.BigEndian.Uint32(childHeader[:4])
|
||||
if childSize == 0 {
|
||||
break
|
||||
}
|
||||
childType := [4]byte{}
|
||||
copy(childType[:], childHeader[4:8])
|
||||
|
||||
atom := mp4Atom{
|
||||
Type: childType,
|
||||
Size: childSize,
|
||||
DataSize: childSize - 8,
|
||||
Offset: childOffset,
|
||||
}
|
||||
|
||||
atoms = append(atoms, atom)
|
||||
childOffset += int64(childSize)
|
||||
}
|
||||
|
||||
return atoms, nil
|
||||
}
|
||||
|
||||
func isContainerAtom(atomType [4]byte) bool {
|
||||
containerTypes := map[string]bool{
|
||||
"moov": true,
|
||||
"trak": true,
|
||||
"mdia": true,
|
||||
"minf": true,
|
||||
"dinf": true,
|
||||
"stbl": true,
|
||||
"udta": true,
|
||||
"ilst": true,
|
||||
"meta": true,
|
||||
"hdlr": true,
|
||||
}
|
||||
return containerTypes[string(atomType[:])]
|
||||
}
|
||||
|
||||
func findMoovAtom(atoms []mp4Atom) *mp4Atom {
|
||||
for i := range atoms {
|
||||
if bytes.Equal(atoms[i].Type[:], []byte("moov")) {
|
||||
return &atoms[i]
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func findILSTAtom(atoms []mp4Atom) *mp4Atom {
|
||||
for i := range atoms {
|
||||
if bytes.Equal(atoms[i].Type[:], []byte("ilst")) {
|
||||
return &atoms[i]
|
||||
}
|
||||
if len(atoms[i].Children) > 0 {
|
||||
if child := findILSTAtom(atoms[i].Children); child != nil {
|
||||
return child
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func encodeILSTMetadata(tags Tags) ([]byte, error) {
|
||||
data := bytes.NewBuffer(nil)
|
||||
|
||||
metadataPairs := map[string]string{
|
||||
"\xa9nam": TagTitle,
|
||||
"\xa9ART": TagArtist,
|
||||
"\xa9alb": TagAlbum,
|
||||
"\xa2A2": TagAlbumArtist,
|
||||
"\xa9day": TagYear,
|
||||
"\xa9gen": TagGenre,
|
||||
"trkn": TagTrackNumber,
|
||||
"disk": TagDiscNumber,
|
||||
"cnmt": TagComment,
|
||||
}
|
||||
|
||||
order := []string{"\xa9nam", "\xa9ART", "\xa9alb", "\xa2A2", "\xa9day", "\xa9gen", "trkn", "disk", "cnmt"}
|
||||
|
||||
for _, key := range order {
|
||||
tagKey := metadataPairs[key]
|
||||
if value, ok := tags[tagKey]; ok && value != "" {
|
||||
atomData := encodeMP4Value(key, value, tagKey)
|
||||
if len(atomData) > 0 {
|
||||
data.Write(atomData)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if data.Len() == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
return data.Bytes(), nil
|
||||
}
|
||||
|
||||
func encodeMP4Value(atomType, value, tagKey string) []byte {
|
||||
var data []byte
|
||||
|
||||
switch tagKey {
|
||||
case TagTrackNumber, TagDiscNumber:
|
||||
data = encodeIntegerList(value, atomType)
|
||||
case TagComment:
|
||||
data = encodeUTF8Text(value, atomType)
|
||||
default:
|
||||
data = encodeUTF8Text(value, atomType)
|
||||
}
|
||||
|
||||
if len(data) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
atomSize := uint32(len(data)) + 8
|
||||
|
||||
atom := make([]byte, 8)
|
||||
binary.BigEndian.PutUint32(atom[0:4], atomSize)
|
||||
copy(atom[4:8], []byte(atomType))
|
||||
|
||||
return append(atom, data...)
|
||||
}
|
||||
|
||||
func encodeUTF8Text(value, atomType string) []byte {
|
||||
data := bytes.NewBuffer(nil)
|
||||
|
||||
locale := []byte{0x00, 0x65, 0x6E, 0x67}
|
||||
|
||||
switch atomType {
|
||||
case "\xa9nam", "\xa9ART", "\xa9alb", "\xa2A2", "\xa9day", "\xa9gen":
|
||||
data.Write(locale)
|
||||
data.WriteString(value)
|
||||
data.WriteByte(0x00)
|
||||
default:
|
||||
data.Write(locale)
|
||||
data.WriteString(value)
|
||||
data.WriteByte(0x00)
|
||||
}
|
||||
|
||||
return data.Bytes()
|
||||
}
|
||||
|
||||
func encodeIntegerList(value, atomType string) []byte {
|
||||
data := bytes.NewBuffer(nil)
|
||||
|
||||
var num, total int
|
||||
fmt.Sscanf(value, "%d/%d", &num, &total)
|
||||
if total == 0 {
|
||||
num, _ = strconv.Atoi(value)
|
||||
}
|
||||
|
||||
atomData := make([]byte, 4)
|
||||
atomData[0] = 0x00
|
||||
binary.BigEndian.PutUint16(atomData[2:], uint16(num))
|
||||
|
||||
data.Write(atomData)
|
||||
|
||||
if total > 0 {
|
||||
totalData := make([]byte, 4)
|
||||
totalData[0] = 0x00
|
||||
binary.BigEndian.PutUint16(totalData[2:], uint16(total))
|
||||
data.Write(totalData)
|
||||
}
|
||||
|
||||
return data.Bytes()
|
||||
}
|
||||
|
||||
func calculateAtomSize(dataSize int) int {
|
||||
return dataSize + 8
|
||||
}
|
||||
|
||||
func writeFullAtom(f *os.File, offset int64, atomType []byte, data []byte) error {
|
||||
atomSize := uint32(len(data)) + 8
|
||||
|
||||
header := make([]byte, 8)
|
||||
binary.BigEndian.PutUint32(header[0:4], atomSize)
|
||||
copy(header[4:8], atomType)
|
||||
|
||||
if _, err := f.WriteAt(header, offset); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := f.WriteAt(data, offset+8); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func writeILSTAtom(f *os.File, offset int64, data []byte) error {
|
||||
return writeFullAtom(f, offset, []byte("ilst"), data)
|
||||
}
|
||||
|
||||
func shiftDataAfter(f *os.File, position int64, delta int64) error {
|
||||
if delta <= 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
fileSize, err := f.Seek(0, os.SEEK_END)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
buf := make([]byte, 8192)
|
||||
for offset := fileSize; offset > position; offset -= int64(len(buf)) {
|
||||
if offset < position+int64(len(buf)) {
|
||||
buf = buf[:offset-position]
|
||||
offset = position
|
||||
}
|
||||
|
||||
dest := offset + delta
|
||||
_, err := f.ReadAt(buf, offset)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = f.WriteAt(buf, dest)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func updateFileTimes(filePath string) error {
|
||||
now := time.Now()
|
||||
return os.Chtimes(filePath, now, now)
|
||||
}
|
||||
|
||||
func init() {
|
||||
_ = os.Stdin
|
||||
}
|
||||
68
tagwriter/mp3.go
Normal file
68
tagwriter/mp3.go
Normal file
@ -0,0 +1,68 @@
|
||||
package tagwriter
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/bogem/id3v2"
|
||||
)
|
||||
|
||||
func writeMP3Tags(filePath string, tags Tags) error {
|
||||
tagFile, err := id3v2.Open(filePath, id3v2.Options{Parse: true})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to open MP3 file: %w", err)
|
||||
}
|
||||
defer tagFile.Close()
|
||||
|
||||
tagFile.SetDefaultEncoding(id3v2.EncodingUTF8)
|
||||
|
||||
if title, ok := tags[TagTitle]; ok && title != "" {
|
||||
tagFile.SetTitle(title)
|
||||
}
|
||||
|
||||
if artist, ok := tags[TagArtist]; ok && artist != "" {
|
||||
tagFile.SetArtist(artist)
|
||||
}
|
||||
|
||||
if album, ok := tags[TagAlbum]; ok && album != "" {
|
||||
tagFile.SetAlbum(album)
|
||||
}
|
||||
|
||||
if albumArtist, ok := tags[TagAlbumArtist]; ok && albumArtist != "" {
|
||||
tagFile.AddTextFrame("TPE1", id3v2.EncodingUTF8, albumArtist)
|
||||
}
|
||||
|
||||
if year, ok := tags[TagYear]; ok && year != "" {
|
||||
tagFile.SetYear(year)
|
||||
}
|
||||
|
||||
if genre, ok := tags[TagGenre]; ok && genre != "" {
|
||||
tagFile.SetGenre(genre)
|
||||
}
|
||||
|
||||
if trackNum, ok := tags[TagTrackNumber]; ok && trackNum != "" {
|
||||
trackTotal, _ := tags[TagTrackTotal]
|
||||
trackFrame := fmt.Sprintf("%s/%s", trackNum, trackTotal)
|
||||
tagFile.AddTextFrame("TRCK", id3v2.EncodingUTF8, trackFrame)
|
||||
}
|
||||
|
||||
if discNum, ok := tags[TagDiscNumber]; ok && discNum != "" {
|
||||
discTotal, _ := tags[TagDiscTotal]
|
||||
discFrame := fmt.Sprintf("%s/%s", discNum, discTotal)
|
||||
tagFile.AddTextFrame("TPOS", id3v2.EncodingUTF8, discFrame)
|
||||
}
|
||||
|
||||
if comment, ok := tags[TagComment]; ok && comment != "" {
|
||||
tagFile.AddCommentFrame(id3v2.CommentFrame{
|
||||
Language: "eng",
|
||||
Description: "",
|
||||
Text: comment,
|
||||
Encoding: id3v2.EncodingUTF8,
|
||||
})
|
||||
}
|
||||
|
||||
if err := tagFile.Save(); err != nil {
|
||||
return fmt.Errorf("failed to save MP3 tags: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
396
tagwriter/ogg.go
Normal file
396
tagwriter/ogg.go
Normal file
@ -0,0 +1,396 @@
|
||||
package tagwriter
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
)
|
||||
|
||||
func writeOGGTags(filePath string, tags Tags) error {
|
||||
f, err := os.OpenFile(filePath, os.O_RDWR, 0)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to open OGG file: %w", err)
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
header, err := readOGGPageHeader(f)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid OGG file: %w", err)
|
||||
}
|
||||
|
||||
if !bytes.Equal(header.Magic[:4], []byte("OggS")) {
|
||||
return errors.New("invalid OGG file: missing OGGS header")
|
||||
}
|
||||
|
||||
pages, err := parseOGGPages(f)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to parse OGG pages: %w", err)
|
||||
}
|
||||
|
||||
vorbisCommentPage, commentSegment, err := findVorbisCommentPage(pages, f)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to find Vorbis comment: %w", err)
|
||||
}
|
||||
|
||||
vorbisData := encodeVorbisCommentsOgg(tags)
|
||||
|
||||
if len(vorbisData) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
if vorbisCommentPage != nil {
|
||||
if err := updateVorbisComment(f, vorbisCommentPage, commentSegment, vorbisData); err != nil {
|
||||
return fmt.Errorf("failed to update Vorbis comment: %w", err)
|
||||
}
|
||||
} else {
|
||||
if err := insertVorbisComment(f, header, vorbisData); err != nil {
|
||||
return fmt.Errorf("failed to insert Vorbis comment: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
recalculateOGGChecksums(f)
|
||||
|
||||
updateFileTimes(filePath)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
type oggPageHeader struct {
|
||||
Magic [4]byte
|
||||
Version byte
|
||||
HeaderType byte
|
||||
GranulePos uint64
|
||||
Serial uint32
|
||||
PageSeq uint32
|
||||
Checksum uint32
|
||||
PageSegments byte
|
||||
}
|
||||
|
||||
type oggPage struct {
|
||||
Header oggPageHeader
|
||||
Offset int64
|
||||
SegmentSizes []byte
|
||||
SegmentsStart int64
|
||||
DataStart int64
|
||||
}
|
||||
|
||||
func readOGGPageHeader(f *os.File) (oggPageHeader, error) {
|
||||
header := make([]byte, 27)
|
||||
_, err := f.Read(header)
|
||||
if err != nil {
|
||||
return oggPageHeader{}, err
|
||||
}
|
||||
|
||||
var h oggPageHeader
|
||||
copy(h.Magic[:], header[0:4])
|
||||
h.Version = header[4]
|
||||
h.HeaderType = header[5]
|
||||
h.GranulePos = binary.LittleEndian.Uint64(header[6:14])
|
||||
h.Serial = binary.LittleEndian.Uint32(header[14:18])
|
||||
h.PageSeq = binary.LittleEndian.Uint32(header[18:22])
|
||||
h.Checksum = binary.LittleEndian.Uint32(header[22:26])
|
||||
h.PageSegments = header[26]
|
||||
|
||||
return h, nil
|
||||
}
|
||||
|
||||
func parseOGGPages(f *os.File) ([]oggPage, error) {
|
||||
var pages []oggPage
|
||||
offset := int64(0)
|
||||
|
||||
for {
|
||||
header, err := readOGGPageHeaderAt(f, offset)
|
||||
if err != nil {
|
||||
if errors.Is(err, io.EOF) {
|
||||
break
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
segmentSizes := make([]byte, header.PageSegments)
|
||||
if _, err := f.ReadAt(segmentSizes, offset+27); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
segmentsStart := offset + 27 + int64(header.PageSegments)
|
||||
dataStart := segmentsStart
|
||||
|
||||
var totalDataSize int64
|
||||
for _, segSize := range segmentSizes {
|
||||
totalDataSize += int64(segSize)
|
||||
}
|
||||
|
||||
page := oggPage{
|
||||
Header: header,
|
||||
Offset: offset,
|
||||
SegmentSizes: segmentSizes,
|
||||
SegmentsStart: segmentsStart,
|
||||
DataStart: dataStart,
|
||||
}
|
||||
pages = append(pages, page)
|
||||
|
||||
pageSize := segmentsStart + totalDataSize - offset
|
||||
offset += pageSize
|
||||
|
||||
if pageSize == 0 {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return pages, nil
|
||||
}
|
||||
|
||||
func readOGGPageHeaderAt(f *os.File, offset int64) (oggPageHeader, error) {
|
||||
header := make([]byte, 27)
|
||||
_, err := f.ReadAt(header, offset)
|
||||
if err != nil {
|
||||
return oggPageHeader{}, err
|
||||
}
|
||||
|
||||
var h oggPageHeader
|
||||
copy(h.Magic[:], header[0:4])
|
||||
h.Version = header[4]
|
||||
h.HeaderType = header[5]
|
||||
h.GranulePos = binary.LittleEndian.Uint64(header[6:14])
|
||||
h.Serial = binary.LittleEndian.Uint32(header[14:18])
|
||||
h.PageSeq = binary.LittleEndian.Uint32(header[18:22])
|
||||
h.Checksum = binary.LittleEndian.Uint32(header[22:26])
|
||||
h.PageSegments = header[26]
|
||||
|
||||
return h, nil
|
||||
}
|
||||
|
||||
func findVorbisCommentPage(pages []oggPage, f *os.File) (*oggPage, int, error) {
|
||||
for i, page := range pages {
|
||||
if page.Header.HeaderType&0x02 == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
if len(page.SegmentSizes) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
data := make([]byte, page.SegmentSizes[0])
|
||||
if _, err := f.ReadAt(data, page.DataStart); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
if len(data) >= 7 && bytes.Equal(data[0:7], []byte("vorbis")) {
|
||||
return &pages[i], 0, nil
|
||||
}
|
||||
|
||||
var cumulative int
|
||||
for segIdx, segSize := range page.SegmentSizes {
|
||||
cumulative += int(segSize)
|
||||
if cumulative >= 7 {
|
||||
headerData := make([]byte, segSize)
|
||||
readOffset := page.DataStart + int64(cumulative - int(segSize))
|
||||
f.ReadAt(headerData, readOffset)
|
||||
if bytes.Equal(headerData[:7], []byte("vorbis")) {
|
||||
return &pages[i], segIdx, nil
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil, 0, errors.New("Vorbis comment header not found - creating new header")
|
||||
|
||||
}
|
||||
|
||||
func encodeVorbisCommentsOgg(tags Tags) []byte {
|
||||
buf := make([]byte, 0)
|
||||
|
||||
vendor := "Navidrome"
|
||||
vendorBytes := []byte(vendor)
|
||||
buf = append(buf, encodeUint32LE(uint32(len(vendorBytes)))...)
|
||||
buf = append(buf, vendorBytes...)
|
||||
|
||||
numComments := countNonEmptyTags(tags)
|
||||
buf = append(buf, encodeUint32LE(uint32(numComments))...)
|
||||
|
||||
commentPairs := map[string]string{
|
||||
"TITLE": TagTitle,
|
||||
"ARTIST": TagArtist,
|
||||
"ALBUM": TagAlbum,
|
||||
"ALBUMARTIST": TagAlbumArtist,
|
||||
"DATE": TagYear,
|
||||
"YEAR": TagYear,
|
||||
"GENRE": TagGenre,
|
||||
"TRACKNUMBER": TagTrackNumber,
|
||||
"TRACKTOTAL": TagTrackTotal,
|
||||
"DISCNUMBER": TagDiscNumber,
|
||||
"DISCTOTAL": TagDiscTotal,
|
||||
"COMMENT": TagComment,
|
||||
}
|
||||
|
||||
for vorbisKey, tagKey := range commentPairs {
|
||||
if value, ok := tags[tagKey]; ok && value != "" {
|
||||
comment := fmt.Sprintf("%s=%s", vorbisKey, value)
|
||||
commentBytes := []byte(comment)
|
||||
buf = append(buf, encodeUint32LE(uint32(len(commentBytes)))...)
|
||||
buf = append(buf, commentBytes...)
|
||||
}
|
||||
}
|
||||
|
||||
return buf
|
||||
}
|
||||
|
||||
func updateVorbisComment(f *os.File, page *oggPage, segmentIdx int, vorbisData []byte) error {
|
||||
pageDataSize := int64(0)
|
||||
for _, segSize := range page.SegmentSizes {
|
||||
pageDataSize += int64(segSize)
|
||||
}
|
||||
|
||||
oldDataSize := int64(0)
|
||||
for i := segmentIdx; i < len(page.SegmentSizes); i++ {
|
||||
oldDataSize += int64(page.SegmentSizes[i])
|
||||
}
|
||||
|
||||
delta := int64(len(vorbisData)) - oldDataSize
|
||||
|
||||
if delta == 0 {
|
||||
dataOffset := page.DataStart + pageDataSize - oldDataSize
|
||||
_, err := f.WriteAt(vorbisData, dataOffset)
|
||||
return err
|
||||
}
|
||||
|
||||
if delta > 0 {
|
||||
fileSize, err := f.Seek(0, os.SEEK_END)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
pageEnd := page.Offset + 27 + int64(page.Header.PageSegments) + pageDataSize
|
||||
|
||||
moveBuf := make([]byte, 4096)
|
||||
for pos := fileSize - 4096; pos >= pageEnd; pos -= 4096 {
|
||||
_, err := f.ReadAt(moveBuf, pos)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = f.WriteAt(moveBuf, pos+delta)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if fileSize-pageEnd < 4096 {
|
||||
remaining := make([]byte, fileSize-pageEnd)
|
||||
f.ReadAt(remaining, pageEnd)
|
||||
f.WriteAt(remaining, pageEnd+delta)
|
||||
}
|
||||
}
|
||||
|
||||
dataOffset := page.DataStart
|
||||
_, err := f.WriteAt(vorbisData, dataOffset)
|
||||
return err
|
||||
}
|
||||
|
||||
func insertVorbisComment(f *os.File, firstPage oggPageHeader, vorbisData []byte) error {
|
||||
commentHeader := createVorbisCommentHeader(vorbisData)
|
||||
|
||||
commentData := append(commentHeader, vorbisData...)
|
||||
|
||||
newFirstPage := firstPage
|
||||
newFirstPage.HeaderType |= 0x01
|
||||
|
||||
newPageSize := 27 + 1 + int64(len(commentData))
|
||||
|
||||
pageData := make([]byte, 0, newPageSize)
|
||||
pageData = append(pageData, []byte("OggS")...)
|
||||
pageData = append(pageData, newFirstPage.Version)
|
||||
pageData = append(pageData, newFirstPage.HeaderType)
|
||||
pageData = append(pageData, make([]byte, 8)...)
|
||||
serialBytes := make([]byte, 4)
|
||||
binary.LittleEndian.PutUint32(serialBytes, newFirstPage.Serial)
|
||||
pageData = append(pageData, serialBytes...)
|
||||
seqBytes := make([]byte, 4)
|
||||
binary.LittleEndian.PutUint32(seqBytes, newFirstPage.PageSeq)
|
||||
pageData = append(pageData, seqBytes...)
|
||||
pageData = append(pageData, make([]byte, 4)...)
|
||||
pageData = append(pageData, 1)
|
||||
pageData = append(pageData, byte(len(commentData)))
|
||||
pageData = append(pageData, commentData...)
|
||||
|
||||
_, err := f.WriteAt(pageData, 0)
|
||||
return err
|
||||
}
|
||||
|
||||
func createVorbisCommentHeader(data []byte) []byte {
|
||||
header := make([]byte, 7)
|
||||
copy(header, []byte("vorbis"))
|
||||
return header
|
||||
}
|
||||
|
||||
func recalculateOGGChecksums(f *os.File) error {
|
||||
offset := int64(0)
|
||||
|
||||
for {
|
||||
header := make([]byte, 27)
|
||||
n, err := f.ReadAt(header, offset)
|
||||
if err != nil || n < 27 {
|
||||
if errors.Is(err, io.EOF) {
|
||||
break
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
if !bytes.Equal(header[0:4], []byte("OggS")) {
|
||||
break
|
||||
}
|
||||
|
||||
pageSegments := header[26]
|
||||
segmentSizes := make([]byte, pageSegments)
|
||||
f.ReadAt(segmentSizes, offset+27)
|
||||
|
||||
var pageSize int64 = 27 + int64(pageSegments)
|
||||
for _, segSize := range segmentSizes {
|
||||
pageSize += int64(segSize)
|
||||
}
|
||||
|
||||
pageData := make([]byte, pageSize)
|
||||
f.ReadAt(pageData, offset)
|
||||
|
||||
checksum := computeCRC(pageData)
|
||||
checksumBytes := make([]byte, 4)
|
||||
binary.LittleEndian.PutUint32(checksumBytes, checksum)
|
||||
|
||||
f.WriteAt(checksumBytes, offset+22)
|
||||
|
||||
offset += pageSize
|
||||
if offset <= 0 {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func computeCRC(data []byte) uint32 {
|
||||
crcTable := make([]uint32, 256)
|
||||
for i := range crcTable {
|
||||
c := uint32(i)
|
||||
for j := 0; j < 8; j++ {
|
||||
if c&1 != 0 {
|
||||
c = 0xedb88320 ^ (c >> 1)
|
||||
} else {
|
||||
c = c >> 1
|
||||
}
|
||||
}
|
||||
crcTable[i] = c
|
||||
}
|
||||
|
||||
var crc uint32 = 0xffffffff
|
||||
for _, b := range data {
|
||||
crc = crcTable[byte(crc)^b] ^ (crc >> 8)
|
||||
}
|
||||
return crc ^ 0xffffffff
|
||||
}
|
||||
|
||||
func init() {
|
||||
_ = os.Stdin
|
||||
}
|
||||
136
tagwriter/tagwriter.go
Normal file
136
tagwriter/tagwriter.go
Normal file
@ -0,0 +1,136 @@
|
||||
package tagwriter
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrFeatureDisabled = errors.New("tag editing is disabled in configuration")
|
||||
ErrUnsupportedFormat = errors.New("unsupported audio file format")
|
||||
ErrReadOnlyFile = errors.New("file is read-only at the OS level")
|
||||
ErrPermissionDenied = errors.New("permission denied")
|
||||
)
|
||||
|
||||
type Tags map[string]string
|
||||
|
||||
const (
|
||||
TagTitle = "title"
|
||||
TagArtist = "artist"
|
||||
TagAlbum = "album"
|
||||
TagAlbumArtist = "albumartist"
|
||||
TagYear = "year"
|
||||
TagGenre = "genre"
|
||||
TagTrackNumber = "tracknumber"
|
||||
TagTrackTotal = "tracktotal"
|
||||
TagDiscNumber = "discnumber"
|
||||
TagDiscTotal = "disctotal"
|
||||
TagComment = "comment"
|
||||
TagAlbumArt = "albumart"
|
||||
)
|
||||
|
||||
type TagWriter interface {
|
||||
WriteTags(filePath string, tags Tags) error
|
||||
}
|
||||
|
||||
func New() TagWriter {
|
||||
return &tagWriter{}
|
||||
}
|
||||
|
||||
type tagWriter struct{}
|
||||
|
||||
func (t *tagWriter) WriteTags(filePath string, tags Tags) error {
|
||||
if !conf.Server.EnableTagEditing {
|
||||
log.Debug("Tag editing is disabled. Enable with config option 'EnableTagEditing'")
|
||||
return ErrFeatureDisabled
|
||||
}
|
||||
|
||||
if len(tags) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
absPath, err := filepath.Abs(filePath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid file path: %w", err)
|
||||
}
|
||||
|
||||
if err := t.checkFilePermissions(absPath); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
ext := strings.ToLower(filepath.Ext(absPath))
|
||||
|
||||
lock, err := LockFile(absPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to acquire file lock: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
if unlockErr := UnlockFile(lock); unlockErr != nil {
|
||||
log.Error("Failed to release file lock", "filePath", absPath, "error", unlockErr)
|
||||
}
|
||||
}()
|
||||
|
||||
var writeErr error
|
||||
switch ext {
|
||||
case ".mp3", ".mp2":
|
||||
writeErr = writeMP3Tags(absPath, tags)
|
||||
case ".flac":
|
||||
writeErr = writeFLACTags(absPath, tags)
|
||||
case ".wav", ".wave":
|
||||
writeErr = writeWAVTags(absPath, tags)
|
||||
case ".m4a", ".mp4":
|
||||
writeErr = writeM4ATags(absPath, tags)
|
||||
case ".ogg":
|
||||
writeErr = writeOGGTags(absPath, tags)
|
||||
default:
|
||||
return ErrUnsupportedFormat
|
||||
}
|
||||
|
||||
if writeErr != nil {
|
||||
return fmt.Errorf("failed to write tags: %w", writeErr)
|
||||
}
|
||||
|
||||
log.Debug("Tags written successfully", "filePath", absPath, "tags", tags)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (t *tagWriter) checkFilePermissions(filePath string) error {
|
||||
info, err := os.Stat(filePath)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return fmt.Errorf("file does not exist: %w", err)
|
||||
}
|
||||
return fmt.Errorf("failed to stat file: %w", err)
|
||||
}
|
||||
|
||||
if info.Mode().IsDir() {
|
||||
return errors.New("path is a directory")
|
||||
}
|
||||
|
||||
if info.Mode().Perm()&0200 == 0 {
|
||||
log.Warn("File is read-only, cannot write tags", "filePath", filePath)
|
||||
return ErrReadOnlyFile
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func SupportedFormats() []string {
|
||||
return []string{".mp3", ".mp2", ".flac", ".wav", ".wave", ".m4a", ".mp4", ".ogg"}
|
||||
}
|
||||
|
||||
func IsSupportedFormat(filePath string) bool {
|
||||
ext := strings.ToLower(filepath.Ext(filePath))
|
||||
for _, supported := range SupportedFormats() {
|
||||
if ext == supported {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
17
tagwriter/tagwriter_suite_test.go
Normal file
17
tagwriter/tagwriter_suite_test.go
Normal file
@ -0,0 +1,17 @@
|
||||
package tagwriter
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
func TestTagWriter(t *testing.T) {
|
||||
tests.Init(t, true)
|
||||
log.SetLevel(log.LevelFatal)
|
||||
RegisterFailHandler(Fail)
|
||||
RunSpecs(t, "TagWriter Suite")
|
||||
}
|
||||
152
tagwriter/tagwriter_test.go
Normal file
152
tagwriter/tagwriter_test.go
Normal file
@ -0,0 +1,152 @@
|
||||
package tagwriter
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("TagWriter", func() {
|
||||
var tw TagWriter
|
||||
var testDir string
|
||||
|
||||
BeforeEach(func() {
|
||||
tw = New()
|
||||
conf.Server.EnableTagEditing = true
|
||||
var err error
|
||||
testDir, err = os.MkdirTemp("", "tagwriter-test")
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
})
|
||||
|
||||
AfterEach(func() {
|
||||
os.RemoveAll(testDir)
|
||||
conf.Server.EnableTagEditing = false
|
||||
ClearLocks()
|
||||
})
|
||||
|
||||
Describe("WriteTags", func() {
|
||||
It("returns error when feature is disabled", func() {
|
||||
conf.Server.EnableTagEditing = false
|
||||
err := tw.WriteTags("test.mp3", Tags{"title": "Test"})
|
||||
Expect(err).To(Equal(ErrFeatureDisabled))
|
||||
})
|
||||
|
||||
It("returns error for unsupported formats", func() {
|
||||
testFile := filepath.Join(testDir, "test.xyz")
|
||||
f, err := os.Create(testFile)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
f.Close()
|
||||
|
||||
err = tw.WriteTags(testFile, Tags{"title": "Test"})
|
||||
Expect(err).To(Equal(ErrUnsupportedFormat))
|
||||
})
|
||||
|
||||
It("returns error for non-existent file", func() {
|
||||
err := tw.WriteTags("/nonexistent/path/test.mp3", Tags{"title": "Test"})
|
||||
Expect(err).To(HaveOccurred())
|
||||
})
|
||||
|
||||
It("returns error for read-only file", func() {
|
||||
testFile := filepath.Join(testDir, "readonly.mp3")
|
||||
f, err := os.Create(testFile)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
f.Close()
|
||||
os.Chmod(testFile, 0444)
|
||||
|
||||
err = tw.WriteTags(testFile, Tags{"title": "Test"})
|
||||
Expect(err).To(Equal(ErrReadOnlyFile))
|
||||
|
||||
os.Chmod(testFile, 0644)
|
||||
})
|
||||
|
||||
It("returns error for directory", func() {
|
||||
err := tw.WriteTags(testDir, Tags{"title": "Test"})
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(err.Error()).To(ContainSubstring("directory"))
|
||||
})
|
||||
|
||||
It("returns no error for empty tags", func() {
|
||||
testFile := filepath.Join(testDir, "test.mp3")
|
||||
f, err := os.Create(testFile)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
f.Close()
|
||||
|
||||
err = tw.WriteTags(testFile, Tags{})
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("SupportedFormats", func() {
|
||||
It("returns supported formats", func() {
|
||||
formats := SupportedFormats()
|
||||
Expect(formats).To(ContainElements(".mp3", ".mp2", ".flac"))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("IsSupportedFormat", func() {
|
||||
It("returns true for supported formats", func() {
|
||||
Expect(IsSupportedFormat("test.mp3")).To(BeTrue())
|
||||
Expect(IsSupportedFormat("test.MP3")).To(BeTrue())
|
||||
Expect(IsSupportedFormat("test.flac")).To(BeTrue())
|
||||
Expect(IsSupportedFormat("test.FLAC")).To(BeTrue())
|
||||
Expect(IsSupportedFormat("test.wav")).To(BeTrue())
|
||||
Expect(IsSupportedFormat("test.WAV")).To(BeTrue())
|
||||
Expect(IsSupportedFormat("test.wave")).To(BeTrue())
|
||||
Expect(IsSupportedFormat("test.m4a")).To(BeTrue())
|
||||
Expect(IsSupportedFormat("test.M4A")).To(BeTrue())
|
||||
Expect(IsSupportedFormat("test.mp4")).To(BeTrue())
|
||||
Expect(IsSupportedFormat("test.ogg")).To(BeTrue())
|
||||
Expect(IsSupportedFormat("test.OGG")).To(BeTrue())
|
||||
})
|
||||
|
||||
It("returns false for unsupported formats", func() {
|
||||
Expect(IsSupportedFormat("test.xyz")).To(BeFalse())
|
||||
Expect(IsSupportedFormat("test.abc")).To(BeFalse())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("File Locking", func() {
|
||||
It("acquires and releases lock", func() {
|
||||
testFile := filepath.Join(testDir, "locktest.mp3")
|
||||
f, err := os.Create(testFile)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
f.Close()
|
||||
|
||||
lock, err := LockFile(testFile)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(lock).NotTo(BeNil())
|
||||
|
||||
err = UnlockFile(lock)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
})
|
||||
|
||||
It("allows multiple locks from same process", func() {
|
||||
testFile := filepath.Join(testDir, "multilock.mp3")
|
||||
f, err := os.Create(testFile)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
f.Close()
|
||||
|
||||
lock1, err := LockFile(testFile)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
|
||||
lock2, err := LockFile(testFile)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
|
||||
Expect(lock1).To(Equal(lock2))
|
||||
|
||||
err = UnlockFile(lock1)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
|
||||
err = UnlockFile(lock2)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
})
|
||||
|
||||
It("returns error for non-existent file in LockFile", func() {
|
||||
_, err := LockFile("/nonexistent/file.mp3")
|
||||
Expect(err).To(HaveOccurred())
|
||||
})
|
||||
})
|
||||
})
|
||||
269
tagwriter/wav.go
Normal file
269
tagwriter/wav.go
Normal file
@ -0,0 +1,269 @@
|
||||
package tagwriter
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
)
|
||||
|
||||
func writeWAVTags(filePath string, tags Tags) error {
|
||||
f, err := os.OpenFile(filePath, os.O_RDWR, 0)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to open WAV file: %w", err)
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
riffHeader := make([]byte, 12)
|
||||
if _, err := f.Read(riffHeader); err != nil {
|
||||
return fmt.Errorf("failed to read RIFF header: %w", err)
|
||||
}
|
||||
|
||||
if !bytes.Equal(riffHeader[:4], []byte("RIFF")) {
|
||||
return errors.New("invalid WAV file: missing RIFF header")
|
||||
}
|
||||
if !bytes.Equal(riffHeader[8:12], []byte("WAVE")[:4]) {
|
||||
return fmt.Errorf("invalid WAV file: expected WAVE format, found %q", string(riffHeader[8:12]))
|
||||
}
|
||||
|
||||
chunks, err := parseRIFFChunks(f)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to parse RIFF chunks: %w", err)
|
||||
}
|
||||
|
||||
id3Chunk := findOrCreateID3Chunk(chunks)
|
||||
|
||||
id3Data, err := encodeID3v2Tags(tags)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to encode ID3v2 tags: %w", err)
|
||||
}
|
||||
|
||||
if len(id3Data) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
if id3Chunk != nil {
|
||||
chunkEnd := id3Chunk.Offset + 8 + int64(id3Chunk.Size)
|
||||
if id3Chunk.Size%2 != 0 {
|
||||
chunkEnd++
|
||||
}
|
||||
if err := f.Truncate(chunkEnd); err != nil {
|
||||
return fmt.Errorf("failed to truncate file: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if _, err := f.Seek(0, io.SeekEnd); err != nil {
|
||||
return fmt.Errorf("failed to seek to end: %w", err)
|
||||
}
|
||||
|
||||
if err := writeRIFFChunk(f, []byte("id3 "), id3Data); err != nil {
|
||||
return fmt.Errorf("failed to write id3 chunk: %w", err)
|
||||
}
|
||||
|
||||
if err := updateRIFFSize(f); err != nil {
|
||||
return fmt.Errorf("failed to update RIFF size: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
type riffChunk struct {
|
||||
ID [4]byte
|
||||
Size uint32
|
||||
Offset int64
|
||||
}
|
||||
|
||||
func parseRIFFChunks(f *os.File) ([]riffChunk, error) {
|
||||
var chunks []riffChunk
|
||||
offset := int64(12)
|
||||
|
||||
for {
|
||||
chunkHeader := make([]byte, 8)
|
||||
n, err := f.ReadAt(chunkHeader, offset)
|
||||
if err != nil {
|
||||
if errors.Is(err, io.EOF) {
|
||||
break
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
if n < 8 {
|
||||
break
|
||||
}
|
||||
|
||||
var chunk riffChunk
|
||||
copy(chunk.ID[:], chunkHeader[:4])
|
||||
chunk.Size = binary.LittleEndian.Uint32(chunkHeader[4:8])
|
||||
chunk.Offset = offset
|
||||
|
||||
chunks = append(chunks, chunk)
|
||||
|
||||
padding := chunk.Size
|
||||
if padding%2 != 0 {
|
||||
padding++
|
||||
}
|
||||
offset += 8 + int64(padding)
|
||||
}
|
||||
|
||||
return chunks, nil
|
||||
}
|
||||
|
||||
func findOrCreateID3Chunk(chunks []riffChunk) *riffChunk {
|
||||
for i := range chunks {
|
||||
if bytes.Equal(chunks[i].ID[:], []byte("id3 ")) {
|
||||
return &chunks[i]
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func encodeID3v2Tags(tags Tags) ([]byte, error) {
|
||||
frames := bytes.NewBuffer(nil)
|
||||
|
||||
if title, ok := tags[TagTitle]; ok && title != "" {
|
||||
frames.Write(createTextFrame("TIT2", title))
|
||||
}
|
||||
|
||||
if artist, ok := tags[TagArtist]; ok && artist != "" {
|
||||
frames.Write(createTextFrame("TPE1", artist))
|
||||
}
|
||||
|
||||
if album, ok := tags[TagAlbum]; ok && album != "" {
|
||||
frames.Write(createTextFrame("TALB", album))
|
||||
}
|
||||
|
||||
if albumArtist, ok := tags[TagAlbumArtist]; ok && albumArtist != "" {
|
||||
frames.Write(createTextFrame("TPE2", albumArtist))
|
||||
}
|
||||
|
||||
if year, ok := tags[TagYear]; ok && year != "" {
|
||||
frames.Write(createTextFrame("TYER", year))
|
||||
}
|
||||
|
||||
if genre, ok := tags[TagGenre]; ok && genre != "" {
|
||||
frames.Write(createTextFrame("TCON", genre))
|
||||
}
|
||||
|
||||
if trackNum, ok := tags[TagTrackNumber]; ok && trackNum != "" {
|
||||
trackTotal, _ := tags[TagTrackTotal]
|
||||
trackFrame := fmt.Sprintf("%s/%s", trackNum, trackTotal)
|
||||
frames.Write(createTextFrame("TRCK", trackFrame))
|
||||
}
|
||||
|
||||
if discNum, ok := tags[TagDiscNumber]; ok && discNum != "" {
|
||||
discTotal, _ := tags[TagDiscTotal]
|
||||
discFrame := fmt.Sprintf("%s/%s", discNum, discTotal)
|
||||
frames.Write(createTextFrame("TPOS", discFrame))
|
||||
}
|
||||
|
||||
if comment, ok := tags[TagComment]; ok && comment != "" {
|
||||
frames.Write(createCommentFrame(comment))
|
||||
}
|
||||
|
||||
if frames.Len() == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
tagSize := syncUint32(uint32(frames.Len()))
|
||||
|
||||
header := make([]byte, 10)
|
||||
copy(header[0:3], []byte("ID3"))
|
||||
header[3] = 0x03
|
||||
header[4] = 0x00
|
||||
header[5] = 0x00
|
||||
copy(header[6:10], tagSize)
|
||||
|
||||
result := bytes.NewBuffer(header)
|
||||
result.Write(frames.Bytes())
|
||||
|
||||
return result.Bytes(), nil
|
||||
}
|
||||
|
||||
func createTextFrame(frameID string, text string) []byte {
|
||||
textData := append([]byte{0x03}, []byte(text)...)
|
||||
|
||||
frame := make([]byte, 10)
|
||||
copy(frame[0:4], []byte(frameID))
|
||||
binary.BigEndian.PutUint32(frame[4:8], uint32(len(textData)))
|
||||
frame[8] = 0x00
|
||||
frame[9] = 0x00
|
||||
|
||||
return append(frame, textData...)
|
||||
}
|
||||
|
||||
func createCommentFrame(text string) []byte {
|
||||
frameData := new(bytes.Buffer)
|
||||
|
||||
frameData.WriteByte(0x03)
|
||||
frameData.WriteString("eng")
|
||||
frameData.WriteByte(0x00)
|
||||
frameData.WriteString("")
|
||||
frameData.WriteByte(0x00)
|
||||
frameData.WriteString(text)
|
||||
|
||||
dataLen := frameData.Len()
|
||||
|
||||
frame := make([]byte, 10)
|
||||
copy(frame[0:4], []byte("COMM"))
|
||||
binary.BigEndian.PutUint32(frame[4:8], uint32(dataLen))
|
||||
frame[8] = 0x00
|
||||
frame[9] = 0x00
|
||||
|
||||
return append(frame, frameData.Bytes()...)
|
||||
}
|
||||
|
||||
func syncUint32(n uint32) []byte {
|
||||
result := make([]byte, 4)
|
||||
result[0] = byte((n >> 21) & 0x7F)
|
||||
result[1] = byte((n >> 14) & 0x7F)
|
||||
result[2] = byte((n >> 7) & 0x7F)
|
||||
result[3] = byte(n & 0x7F)
|
||||
return result
|
||||
}
|
||||
|
||||
func writeRIFFChunk(f *os.File, id []byte, data []byte) error {
|
||||
chunk := make([]byte, 8)
|
||||
copy(chunk[:4], id)
|
||||
binary.LittleEndian.PutUint32(chunk[4:8], uint32(len(data)))
|
||||
|
||||
if _, err := f.Write(chunk); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := f.Write(data); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if len(data)%2 != 0 {
|
||||
if _, err := f.Write([]byte{0}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func updateRIFFSize(f *os.File) error {
|
||||
fileSize, err := f.Seek(0, io.SeekEnd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
riffSize := uint32(fileSize - 8)
|
||||
if riffSize%2 != 0 {
|
||||
riffSize++
|
||||
}
|
||||
|
||||
sizeBytes := make([]byte, 4)
|
||||
binary.LittleEndian.PutUint32(sizeBytes, riffSize)
|
||||
|
||||
if _, err := f.WriteAt(sizeBytes, 4); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func init() {
|
||||
_ = os.Stdin
|
||||
}
|
||||
@ -1,3 +1,4 @@
|
||||
import React, { useState, useCallback, useEffect } from 'react'
|
||||
import Table from '@material-ui/core/Table'
|
||||
import TableBody from '@material-ui/core/TableBody'
|
||||
import { humanize, underscore } from 'inflection'
|
||||
@ -14,14 +15,24 @@ import {
|
||||
TextField,
|
||||
useRecordContext,
|
||||
useTranslate,
|
||||
useNotify,
|
||||
useRefresh,
|
||||
} from 'react-admin'
|
||||
import { makeStyles } from '@material-ui/core/styles'
|
||||
import {
|
||||
Button,
|
||||
TextField as MuiTextField,
|
||||
CircularProgress,
|
||||
} from '@material-ui/core'
|
||||
import EditIcon from '@material-ui/icons/Edit'
|
||||
import {
|
||||
ArtistLinkField,
|
||||
MultiLineTextField,
|
||||
ParticipantsInfo,
|
||||
RangeField,
|
||||
} from '../common'
|
||||
import config from '../config'
|
||||
import httpClient from '../dataProvider/httpClient'
|
||||
|
||||
const useStyles = makeStyles({
|
||||
tableCell: {
|
||||
@ -32,94 +43,246 @@ const useStyles = makeStyles({
|
||||
},
|
||||
})
|
||||
|
||||
const EDITABLE_FIELDS = ['name', 'albumArtist', 'genre', 'year']
|
||||
|
||||
const AlbumInfo = (props) => {
|
||||
const classes = useStyles()
|
||||
const translate = useTranslate()
|
||||
const record = useRecordContext(props)
|
||||
const data = {
|
||||
name: <TextField source={'name'} />,
|
||||
libraryName: <TextField source="libraryName" />,
|
||||
albumArtist: (
|
||||
<ArtistLinkField source="albumArtist" record={record} limit={Infinity} />
|
||||
),
|
||||
genre: (
|
||||
<ArrayField source={'genres'}>
|
||||
<SingleFieldList linkType={false}>
|
||||
<ChipField source={'name'} />
|
||||
</SingleFieldList>
|
||||
</ArrayField>
|
||||
),
|
||||
date:
|
||||
record?.maxYear && record.maxYear === record.minYear ? (
|
||||
<TextField source={'date'} />
|
||||
) : (
|
||||
<RangeField source={'year'} />
|
||||
),
|
||||
originalDate:
|
||||
record?.maxOriginalYear &&
|
||||
record.maxOriginalYear === record.minOriginalYear ? (
|
||||
<TextField source={'originalDate'} />
|
||||
) : (
|
||||
<RangeField source={'originalYear'} />
|
||||
),
|
||||
releaseDate: <TextField source={'releaseDate'} />,
|
||||
recordLabel: (
|
||||
<FunctionField
|
||||
source={'recordLabel'}
|
||||
render={(record) => record.tags?.recordlabel?.join(', ')}
|
||||
/>
|
||||
),
|
||||
catalogNum: <TextField source={'catalogNum'} />,
|
||||
releaseType: (
|
||||
<FunctionField
|
||||
source={'releaseType'}
|
||||
render={(record) => record.tags?.releasetype?.join(', ')}
|
||||
/>
|
||||
),
|
||||
media: (
|
||||
<FunctionField
|
||||
source={'media'}
|
||||
render={(record) => record.tags?.media?.join(', ')}
|
||||
/>
|
||||
),
|
||||
grouping: (
|
||||
<FunctionField
|
||||
source={'grouping'}
|
||||
render={(record) => record.tags?.grouping?.join(', ')}
|
||||
/>
|
||||
),
|
||||
mood: (
|
||||
<FunctionField
|
||||
source={'mood'}
|
||||
render={(record) => record.tags?.mood?.join(', ')}
|
||||
/>
|
||||
),
|
||||
compilation: <BooleanField source={'compilation'} />,
|
||||
updatedAt: <DateField source={'updatedAt'} showTime />,
|
||||
comment: <MultiLineTextField source={'comment'} />,
|
||||
}
|
||||
|
||||
const optionalFields = ['comment', 'genre', 'catalogNum']
|
||||
optionalFields.forEach((field) => {
|
||||
!record[field] && delete data[field]
|
||||
const notify = useNotify()
|
||||
const refresh = useRefresh()
|
||||
const [isEditing, setIsEditing] = useState(false)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [formData, setFormData] = useState({
|
||||
name: '',
|
||||
albumArtist: '',
|
||||
genre: '',
|
||||
year: '',
|
||||
})
|
||||
|
||||
const optionalTags = [
|
||||
'releaseType',
|
||||
useEffect(() => {
|
||||
if (record && isEditing) {
|
||||
setFormData({
|
||||
name: record.name || '',
|
||||
albumArtist: record.albumArtist || '',
|
||||
genre: record.genres?.map((g) => g.name).join(' • ') || '',
|
||||
year: record.year || '',
|
||||
})
|
||||
}
|
||||
}, [record, isEditing])
|
||||
|
||||
const startEdit = useCallback(() => {
|
||||
setFormData({
|
||||
name: record.name || '',
|
||||
albumArtist: record.albumArtist || '',
|
||||
genre: record.genres?.map((g) => g.name).join(' • ') || '',
|
||||
year: record.year || '',
|
||||
})
|
||||
setIsEditing(true)
|
||||
}, [record])
|
||||
|
||||
const cancelEdit = useCallback(() => {
|
||||
setIsEditing(false)
|
||||
}, [])
|
||||
|
||||
const handleFieldChange = useCallback((field) => (event) => {
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
[field]: event.target.value,
|
||||
}))
|
||||
}, [])
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
if (!record?.id) return
|
||||
|
||||
setSaving(true)
|
||||
const payload = {
|
||||
album: formData.name,
|
||||
albumArtist: formData.albumArtist,
|
||||
genre: formData.genre,
|
||||
year: formData.year ? parseInt(formData.year, 10) : null,
|
||||
}
|
||||
console.log('DEBUG: Sending Payload', payload)
|
||||
|
||||
try {
|
||||
await httpClient(`/api/album/${record.id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
notify('Album updated', { type: 'success' })
|
||||
refresh()
|
||||
setFormData({
|
||||
name: payload.album,
|
||||
albumArtist: payload.albumArtist,
|
||||
genre: payload.genre,
|
||||
year: payload.year ? String(payload.year) : '',
|
||||
})
|
||||
setIsEditing(false)
|
||||
} catch (error) {
|
||||
console.error('Error updating album:', error)
|
||||
notify('Error updating album. Check console for details.', { type: 'error' })
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}, [record, formData, notify, refresh])
|
||||
|
||||
const buildField = (key) => {
|
||||
if (isEditing) {
|
||||
if (EDITABLE_FIELDS.includes(key)) {
|
||||
return (
|
||||
<MuiTextField
|
||||
value={formData[key] || ''}
|
||||
onChange={handleFieldChange(key)}
|
||||
variant="outlined"
|
||||
size="small"
|
||||
fullWidth
|
||||
disabled={saving}
|
||||
/>
|
||||
)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const viewFields = {
|
||||
name: formData.name || <TextField source={'name'} />,
|
||||
libraryName: <TextField source="libraryName" />,
|
||||
albumArtist: formData.albumArtist || (
|
||||
<ArtistLinkField source="albumArtist" record={record} limit={Infinity} />
|
||||
),
|
||||
genre: formData.genre || (
|
||||
<ArrayField source={'genres'}>
|
||||
<SingleFieldList linkType={false}>
|
||||
<ChipField source={'name'} />
|
||||
</SingleFieldList>
|
||||
</ArrayField>
|
||||
),
|
||||
date:
|
||||
record?.maxYear && record.maxYear === record.minYear ? (
|
||||
formData.year ? parseInt(formData.year, 10) : <TextField source={'date'} />
|
||||
) : (
|
||||
<RangeField source={'year'} />
|
||||
),
|
||||
originalDate:
|
||||
record?.maxOriginalYear &&
|
||||
record.maxOriginalYear === record.minOriginalYear ? (
|
||||
<TextField source={'originalDate'} />
|
||||
) : (
|
||||
<RangeField source={'originalYear'} />
|
||||
),
|
||||
releaseDate: <TextField source={'releaseDate'} />,
|
||||
recordLabel: (
|
||||
<FunctionField
|
||||
source={'recordLabel'}
|
||||
render={(record) => record.tags?.recordlabel?.join(', ')}
|
||||
/>
|
||||
),
|
||||
catalogNum: <TextField source={'catalogNum'} />,
|
||||
releaseType: (
|
||||
<FunctionField
|
||||
source={'releaseType'}
|
||||
render={(record) => record.tags?.releasetype?.join(', ')}
|
||||
/>
|
||||
),
|
||||
media: (
|
||||
<FunctionField
|
||||
source={'media'}
|
||||
render={(record) => record.tags?.media?.join(', ')}
|
||||
/>
|
||||
),
|
||||
grouping: (
|
||||
<FunctionField
|
||||
source={'grouping'}
|
||||
render={(record) => record.tags?.grouping?.join(', ')}
|
||||
/>
|
||||
),
|
||||
mood: (
|
||||
<FunctionField
|
||||
source={'mood'}
|
||||
render={(record) => record.tags?.mood?.join(', ')}
|
||||
/>
|
||||
),
|
||||
compilation: <BooleanField source={'compilation'} />,
|
||||
updatedAt: <DateField source={'updatedAt'} showTime />,
|
||||
comment: <MultiLineTextField source={'comment'} />,
|
||||
}
|
||||
return viewFields[key]
|
||||
}
|
||||
|
||||
const allFields = [
|
||||
'name',
|
||||
'libraryName',
|
||||
'albumArtist',
|
||||
'genre',
|
||||
'date',
|
||||
'originalDate',
|
||||
'releaseDate',
|
||||
'recordLabel',
|
||||
'catalogNum',
|
||||
'releaseType',
|
||||
'media',
|
||||
'grouping',
|
||||
'mood',
|
||||
'media',
|
||||
'compilation',
|
||||
'updatedAt',
|
||||
'comment',
|
||||
]
|
||||
optionalTags.forEach((field) => {
|
||||
!record?.tags?.[field.toLowerCase()] && delete data[field]
|
||||
|
||||
const optionalFields = ['comment', 'genre', 'catalogNum']
|
||||
const optionalTags = ['releaseType', 'recordLabel', 'grouping', 'mood', 'media']
|
||||
const editableExceptions = ['libraryName', 'date', 'originalDate', 'releaseDate', 'recordLabel', 'catalogNum', 'releaseType', 'media', 'grouping', 'mood', 'compilation', 'updatedAt', 'comment']
|
||||
|
||||
let fieldsToShow = allFields.filter((field) => {
|
||||
if (!isEditing && optionalFields.includes(field) && !record[field]) return false
|
||||
if (!isEditing && optionalTags.includes(field)) {
|
||||
if (!record?.tags?.[field.toLowerCase()]) return false
|
||||
}
|
||||
if (isEditing && !EDITABLE_FIELDS.includes(field) && !editableExceptions.includes(field)) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
return (
|
||||
<TableContainer>
|
||||
<div style={{ textAlign: 'right', marginBottom: 8 }}>
|
||||
{config.enableTagEditing && !isEditing && (
|
||||
<Button
|
||||
startIcon={<EditIcon />}
|
||||
onClick={startEdit}
|
||||
variant="outlined"
|
||||
size="small"
|
||||
>
|
||||
{translate('ra.action.edit')}
|
||||
</Button>
|
||||
)}
|
||||
{isEditing && (
|
||||
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
|
||||
<Button
|
||||
onClick={cancelEdit}
|
||||
disabled={saving}
|
||||
variant="outlined"
|
||||
size="small"
|
||||
>
|
||||
{translate('ra.action.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
variant="contained"
|
||||
color="primary"
|
||||
size="small"
|
||||
startIcon={saving ? <CircularProgress size={16} color="inherit" /> : null}
|
||||
>
|
||||
{translate('ra.action.save')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Table aria-label="album details" size="small">
|
||||
<TableBody>
|
||||
{Object.keys(data).map((key) => {
|
||||
{fieldsToShow.map((key) => {
|
||||
const cellContent = buildField(key)
|
||||
if (!cellContent) return null
|
||||
return (
|
||||
<TableRow key={`${record.id}-${key}`}>
|
||||
<TableCell
|
||||
@ -133,12 +296,12 @@ const AlbumInfo = (props) => {
|
||||
:
|
||||
</TableCell>
|
||||
<TableCell align="left" className={classes.value}>
|
||||
{data[key]}
|
||||
{cellContent}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
})}
|
||||
<ParticipantsInfo record={record} classes={classes} />
|
||||
{!isEditing && <ParticipantsInfo record={record} classes={classes} />}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import React, { useState } from 'react'
|
||||
import React, { useState, useCallback, useEffect } from 'react'
|
||||
import Table from '@material-ui/core/Table'
|
||||
import TableBody from '@material-ui/core/TableBody'
|
||||
import TableCell from '@material-ui/core/TableCell'
|
||||
@ -12,6 +12,8 @@ import {
|
||||
FunctionField,
|
||||
useTranslate,
|
||||
useRecordContext,
|
||||
useNotify,
|
||||
useRefresh,
|
||||
} from 'react-admin'
|
||||
import { humanize, underscore } from 'inflection'
|
||||
import {
|
||||
@ -23,9 +25,16 @@ import {
|
||||
} from './index'
|
||||
import { MultiLineTextField } from './MultiLineTextField'
|
||||
import { makeStyles } from '@material-ui/core/styles'
|
||||
import {
|
||||
Button,
|
||||
TextField as MuiTextField,
|
||||
CircularProgress,
|
||||
} from '@material-ui/core'
|
||||
import EditIcon from '@material-ui/icons/Edit'
|
||||
import config from '../config'
|
||||
import { AlbumLinkField } from '../song/AlbumLinkField'
|
||||
import { Tab, Tabs } from '@material-ui/core'
|
||||
import httpClient from '../dataProvider/httpClient'
|
||||
|
||||
const useStyles = makeStyles({
|
||||
gain: {
|
||||
@ -41,13 +50,132 @@ const useStyles = makeStyles({
|
||||
},
|
||||
})
|
||||
|
||||
const EDITABLE_FIELDS = [
|
||||
'title',
|
||||
'artist',
|
||||
'albumArtist',
|
||||
'album',
|
||||
'genre',
|
||||
'year',
|
||||
'trackNumber',
|
||||
]
|
||||
|
||||
const READONLY_FIELDS = [
|
||||
'path',
|
||||
'libraryName',
|
||||
'discSubtitle',
|
||||
'bitRate',
|
||||
'bitDepth',
|
||||
'sampleRate',
|
||||
'channels',
|
||||
'size',
|
||||
'updatedAt',
|
||||
'playCount',
|
||||
'bpm',
|
||||
'comment',
|
||||
'compilation',
|
||||
'playDate',
|
||||
'albumGain',
|
||||
'trackGain',
|
||||
]
|
||||
|
||||
export const SongInfo = (props) => {
|
||||
const classes = useStyles({ gain: config.enableReplayGain })
|
||||
const translate = useTranslate()
|
||||
const record = useRecordContext(props)
|
||||
const notify = useNotify()
|
||||
const refresh = useRefresh()
|
||||
const [tab, setTab] = useState(0)
|
||||
const [editMode, setEditMode] = useState(false)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [formData, setFormData] = useState({
|
||||
title: '',
|
||||
artist: '',
|
||||
albumArtist: '',
|
||||
album: '',
|
||||
genre: '',
|
||||
year: '',
|
||||
trackNumber: '',
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (record && editMode) {
|
||||
setFormData({
|
||||
title: record.title || '',
|
||||
artist: record.artist || '',
|
||||
albumArtist: record.albumArtist || '',
|
||||
album: record.album || '',
|
||||
genre: record.genres?.map((g) => g.name).join(' • ') || '',
|
||||
year: record.year || '',
|
||||
trackNumber: record.trackNumber || '',
|
||||
})
|
||||
}
|
||||
}, [record, editMode])
|
||||
|
||||
const startEdit = useCallback(() => {
|
||||
setFormData({
|
||||
title: record.title || '',
|
||||
artist: record.artist || '',
|
||||
albumArtist: record.albumArtist || '',
|
||||
album: record.album || '',
|
||||
genre: record.genres?.map((g) => g.name).join(' • ') || '',
|
||||
year: record.year || '',
|
||||
trackNumber: record.trackNumber || '',
|
||||
})
|
||||
setEditMode(true)
|
||||
}, [record])
|
||||
|
||||
const cancelEdit = useCallback(() => {
|
||||
setEditMode(false)
|
||||
}, [])
|
||||
|
||||
const handleFieldChange = useCallback((field) => (event) => {
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
[field]: event.target.value,
|
||||
}))
|
||||
}, [])
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
if (!record?.id) return
|
||||
|
||||
setSaving(true)
|
||||
const payload = {
|
||||
title: formData.title,
|
||||
artist: formData.artist,
|
||||
album: formData.album,
|
||||
albumArtist: formData.albumArtist,
|
||||
genre: formData.genre,
|
||||
year: formData.year ? parseInt(formData.year, 10) : null,
|
||||
trackNumber: formData.trackNumber ? parseInt(formData.trackNumber, 10) : null,
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await httpClient(`/api/song/${record.id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
console.log('Song update response:', response)
|
||||
notify('Song updated successfully', { type: 'success' })
|
||||
refresh()
|
||||
setEditMode(false)
|
||||
setFormData({
|
||||
title: payload.title,
|
||||
artist: payload.artist,
|
||||
album: payload.album,
|
||||
albumArtist: payload.albumArtist,
|
||||
genre: payload.genre,
|
||||
year: payload.year ? String(payload.year) : '',
|
||||
trackNumber: payload.trackNumber ? String(payload.trackNumber) : '',
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Error updating song:', error)
|
||||
notify('Error updating song. Check console for details.', { type: 'error' })
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}, [record, formData, notify, refresh])
|
||||
|
||||
// These are already displayed in other fields or are album-level tags
|
||||
const excludedTags = [
|
||||
'genre',
|
||||
'disctotal',
|
||||
@ -57,43 +185,89 @@ export const SongInfo = (props) => {
|
||||
'media',
|
||||
'albumversion',
|
||||
]
|
||||
const data = {
|
||||
path: <PathField />,
|
||||
libraryName: <TextField source="libraryName" />,
|
||||
album: (
|
||||
<AlbumLinkField source="album" sortByOrder={'ASC'} record={record} />
|
||||
),
|
||||
discSubtitle: <TextField source="discSubtitle" />,
|
||||
albumArtist: (
|
||||
<ArtistLinkField source="albumArtist" record={record} limit={Infinity} />
|
||||
),
|
||||
artist: (
|
||||
<ArtistLinkField source="artist" record={record} limit={Infinity} />
|
||||
),
|
||||
genre: (
|
||||
<FunctionField render={(r) => r.genres?.map((g) => g.name).join(' • ')} />
|
||||
),
|
||||
compilation: <BooleanField source="compilation" />,
|
||||
bitRate: <BitrateField source="bitRate" />,
|
||||
bitDepth: <NumberField source="bitDepth" />,
|
||||
sampleRate: <NumberField source="sampleRate" />,
|
||||
channels: <NumberField source="channels" />,
|
||||
size: <SizeField source="size" />,
|
||||
updatedAt: <DateField source="updatedAt" showTime />,
|
||||
playCount: <TextField source="playCount" />,
|
||||
bpm: <NumberField source="bpm" />,
|
||||
comment: <MultiLineTextField source="comment" />,
|
||||
}
|
||||
|
||||
const roles = []
|
||||
|
||||
for (const name of Object.keys(record.participants)) {
|
||||
if (name === 'albumartist' || name === 'artist') {
|
||||
continue
|
||||
const buildRow = (key) => {
|
||||
if (editMode) {
|
||||
if (EDITABLE_FIELDS.includes(key)) {
|
||||
return (
|
||||
<MuiTextField
|
||||
value={formData[key] || ''}
|
||||
onChange={handleFieldChange(key)}
|
||||
variant="outlined"
|
||||
size="small"
|
||||
fullWidth
|
||||
disabled={saving}
|
||||
/>
|
||||
)
|
||||
}
|
||||
if (READONLY_FIELDS.includes(key)) {
|
||||
const readOnlyFields = {
|
||||
path: <PathField />,
|
||||
libraryName: <TextField source="libraryName" />,
|
||||
discSubtitle: <TextField source="discSubtitle" />,
|
||||
bitRate: <BitrateField source="bitRate" />,
|
||||
bitDepth: <NumberField source="bitDepth" />,
|
||||
sampleRate: <NumberField source="sampleRate" />,
|
||||
channels: <NumberField source="channels" />,
|
||||
size: <SizeField source="size" />,
|
||||
updatedAt: <DateField source="updatedAt" showTime />,
|
||||
playCount: <TextField source="playCount" />,
|
||||
bpm: <NumberField source="bpm" />,
|
||||
comment: <MultiLineTextField source="comment" />,
|
||||
compilation: <BooleanField source="compilation" />,
|
||||
}
|
||||
return readOnlyFields[key] || null
|
||||
}
|
||||
return null
|
||||
}
|
||||
roles.push([name, record.participants[name].length])
|
||||
|
||||
const viewFields = {
|
||||
title: formData.title || <TextField source="title" />,
|
||||
libraryName: <TextField source="libraryName" />,
|
||||
album: formData.album || <AlbumLinkField source="album" sortByOrder={'ASC'} record={record} />,
|
||||
discSubtitle: <TextField source="discSubtitle" />,
|
||||
albumArtist: formData.albumArtist || <ArtistLinkField source="albumArtist" record={record} limit={Infinity} />,
|
||||
artist: formData.artist || <ArtistLinkField source="artist" record={record} limit={Infinity} />,
|
||||
genre: formData.genre || <FunctionField render={(r) => r.genres?.map((g) => g.name).join(' • ')} />,
|
||||
compilation: <BooleanField source="compilation" />,
|
||||
bitRate: <BitrateField source="bitRate" />,
|
||||
bitDepth: <NumberField source="bitDepth" />,
|
||||
sampleRate: <NumberField source="sampleRate" />,
|
||||
channels: <NumberField source="channels" />,
|
||||
size: <SizeField source="size" />,
|
||||
updatedAt: <DateField source="updatedAt" showTime />,
|
||||
playCount: <TextField source="playCount" />,
|
||||
bpm: <NumberField source="bpm" />,
|
||||
comment: <MultiLineTextField source="comment" />,
|
||||
year: formData.year ? parseInt(formData.year, 10) : <NumberField source="year" />,
|
||||
trackNumber: formData.trackNumber ? parseInt(formData.trackNumber, 10) : <NumberField source="trackNumber" />,
|
||||
}
|
||||
return viewFields[key] || null
|
||||
}
|
||||
|
||||
const allFields = [
|
||||
'title',
|
||||
'artist',
|
||||
'albumArtist',
|
||||
'album',
|
||||
'genre',
|
||||
'year',
|
||||
'trackNumber',
|
||||
'path',
|
||||
'libraryName',
|
||||
'discSubtitle',
|
||||
'bitRate',
|
||||
'bitDepth',
|
||||
'sampleRate',
|
||||
'channels',
|
||||
'size',
|
||||
'updatedAt',
|
||||
'playCount',
|
||||
'bpm',
|
||||
'comment',
|
||||
'compilation',
|
||||
]
|
||||
|
||||
const optionalFields = [
|
||||
'discSubtitle',
|
||||
'comment',
|
||||
@ -102,29 +276,39 @@ export const SongInfo = (props) => {
|
||||
'bitDepth',
|
||||
'sampleRate',
|
||||
]
|
||||
optionalFields.forEach((field) => {
|
||||
!record[field] && delete data[field]
|
||||
const fieldsToShow = allFields.filter((field) => {
|
||||
if (editMode) return true
|
||||
if (!record[field] && optionalFields.includes(field.toLowerCase())) return false
|
||||
if (field === 'playCount' && record.playCount <= 0) return false
|
||||
return true
|
||||
})
|
||||
if (record.playCount > 0) {
|
||||
data.playDate = <DateField record={record} source="playDate" showTime />
|
||||
|
||||
if (editMode && record.playCount > 0) {
|
||||
if (!fieldsToShow.includes('playDate')) {
|
||||
fieldsToShow.push('playDate')
|
||||
}
|
||||
}
|
||||
|
||||
if (config.enableReplayGain) {
|
||||
data.albumGain = (
|
||||
<NumberField source="rgAlbumGain" className={classes.gain} />
|
||||
)
|
||||
data.trackGain = (
|
||||
<NumberField source="rgTrackGain" className={classes.gain} />
|
||||
)
|
||||
if (config.enableReplayGain && !editMode) {
|
||||
if (!fieldsToShow.includes('albumGain')) {
|
||||
fieldsToShow.push('albumGain')
|
||||
}
|
||||
if (!fieldsToShow.includes('trackGain')) {
|
||||
fieldsToShow.push('trackGain')
|
||||
}
|
||||
}
|
||||
|
||||
const tags = Object.entries(record.tags ?? {}).filter(
|
||||
(tag) => !excludedTags.includes(tag[0]),
|
||||
)
|
||||
|
||||
const showEditButton = config.enableTagEditing && !editMode
|
||||
const showSaveCancel = editMode
|
||||
const showTabs = record.rawTags && !editMode
|
||||
|
||||
return (
|
||||
<TableContainer>
|
||||
{record.rawTags && (
|
||||
{showTabs && (
|
||||
<Tabs value={tab} onChange={(_, value) => setTab(value)}>
|
||||
<Tab
|
||||
label={translate(`resources.song.fields.mappedTags`)}
|
||||
@ -138,16 +322,125 @@ export const SongInfo = (props) => {
|
||||
/>
|
||||
</Tabs>
|
||||
)}
|
||||
<div
|
||||
hidden={tab === 1}
|
||||
id="mapped-tags-body"
|
||||
aria-labelledby={record.rawTags ? 'mapped-tags-tab' : undefined}
|
||||
>
|
||||
<div style={{ textAlign: 'right', marginBottom: 8 }}>
|
||||
{showEditButton && (
|
||||
<Button
|
||||
startIcon={<EditIcon />}
|
||||
onClick={startEdit}
|
||||
variant="outlined"
|
||||
size="small"
|
||||
>
|
||||
{translate('ra.action.edit')}
|
||||
</Button>
|
||||
)}
|
||||
{showSaveCancel && (
|
||||
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
|
||||
<Button
|
||||
onClick={cancelEdit}
|
||||
disabled={saving}
|
||||
variant="outlined"
|
||||
size="small"
|
||||
>
|
||||
{translate('ra.action.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
variant="contained"
|
||||
color="primary"
|
||||
size="small"
|
||||
startIcon={saving ? <CircularProgress size={16} color="inherit" /> : null}
|
||||
>
|
||||
{translate('ra.action.save')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{showTabs ? (
|
||||
<>
|
||||
<div
|
||||
hidden={tab !== 0}
|
||||
id="mapped-tags-body"
|
||||
aria-labelledby="mapped-tags-tab"
|
||||
>
|
||||
<Table aria-label="song details" size="small">
|
||||
<TableBody>
|
||||
{fieldsToShow.map((key) => {
|
||||
const cellContent = buildRow(key)
|
||||
if (!cellContent) return null
|
||||
return (
|
||||
<TableRow key={`${record?.id}-${key}`}>
|
||||
<TableCell scope="row" className={classes.tableCell}>
|
||||
{translate(`resources.song.fields.${key}`, {
|
||||
_: humanize(underscore(key)),
|
||||
})}
|
||||
:
|
||||
</TableCell>
|
||||
<TableCell align="left" className={classes.value}>
|
||||
{cellContent}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
})}
|
||||
{!editMode && <ParticipantsInfo classes={classes} record={record} />}
|
||||
{tags.length > 0 && !editMode && (
|
||||
<TableRow key={`${record?.id}-separator`}>
|
||||
<TableCell scope="row" className={classes.tableCell} />
|
||||
<TableCell align="left" className={classes.value}>
|
||||
<h4>{translate(`resources.song.fields.tags`)}</h4>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
{tags.map(([name, values]) => (
|
||||
<TableRow key={`${record?.id}-tag-${name}`}>
|
||||
<TableCell scope="row" className={classes.tableCell}>
|
||||
{name}:
|
||||
</TableCell>
|
||||
<TableCell align="left" className={classes.value}>
|
||||
{values.join(' • ')}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
<div
|
||||
hidden={tab !== 1}
|
||||
id="raw-tags-body"
|
||||
aria-labelledby="raw-tags-tab"
|
||||
>
|
||||
<Table size="small" aria-label="song raw tags">
|
||||
<TableBody>
|
||||
<TableRow key={`${record?.id}-raw-path`}>
|
||||
<TableCell scope="row" className={classes.tableCell}>
|
||||
{translate(`resources.song.fields.path`)}:
|
||||
</TableCell>
|
||||
<TableCell align="left">
|
||||
<PathField />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
{Object.entries(record.rawTags || {}).map(([key, value]) => (
|
||||
<TableRow key={`${record?.id}-raw-${key}`}>
|
||||
<TableCell scope="row" className={classes.tableCell}>
|
||||
{key}:
|
||||
</TableCell>
|
||||
<TableCell align="left" className={classes.value}>
|
||||
{value.join(' • ')}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<Table aria-label="song details" size="small">
|
||||
<TableBody>
|
||||
{Object.keys(data).map((key) => {
|
||||
{fieldsToShow.map((key) => {
|
||||
const cellContent = buildRow(key)
|
||||
if (!cellContent) return null
|
||||
return (
|
||||
<TableRow key={`${record.id}-${key}`}>
|
||||
<TableRow key={`${record?.id}-${key}`}>
|
||||
<TableCell scope="row" className={classes.tableCell}>
|
||||
{translate(`resources.song.fields.${key}`, {
|
||||
_: humanize(underscore(key)),
|
||||
@ -155,25 +448,22 @@ export const SongInfo = (props) => {
|
||||
:
|
||||
</TableCell>
|
||||
<TableCell align="left" className={classes.value}>
|
||||
{data[key]}
|
||||
{cellContent}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
})}
|
||||
<ParticipantsInfo classes={classes} record={record} />
|
||||
{tags.length > 0 && (
|
||||
<TableRow key={`${record.id}-separator`}>
|
||||
<TableCell
|
||||
scope="row"
|
||||
className={classes.tableCell}
|
||||
></TableCell>
|
||||
{!editMode && <ParticipantsInfo classes={classes} record={record} />}
|
||||
{tags.length > 0 && !editMode && (
|
||||
<TableRow key={`${record?.id}-separator`}>
|
||||
<TableCell scope="row" className={classes.tableCell} />
|
||||
<TableCell align="left" className={classes.value}>
|
||||
<h4>{translate(`resources.song.fields.tags`)}</h4>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
{tags.map(([name, values]) => (
|
||||
<TableRow key={`${record.id}-tag-${name}`}>
|
||||
<TableRow key={`${record?.id}-tag-${name}`}>
|
||||
<TableCell scope="row" className={classes.tableCell}>
|
||||
{name}:
|
||||
</TableCell>
|
||||
@ -184,35 +474,7 @@ export const SongInfo = (props) => {
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
{record.rawTags && (
|
||||
<div
|
||||
hidden={tab === 0}
|
||||
id="raw-tags-body"
|
||||
aria-labelledby="raw-tags-tab"
|
||||
>
|
||||
<Table size="small" aria-label="song raw tags">
|
||||
<TableBody>
|
||||
<TableRow key={`${record.id}-raw-path`}>
|
||||
<TableCell scope="row" className={classes.tableCell}>
|
||||
{translate(`resources.song.fields.path`)}:
|
||||
</TableCell>
|
||||
<TableCell align="left">{data.path}</TableCell>
|
||||
</TableRow>
|
||||
{Object.entries(record.rawTags).map(([key, value]) => (
|
||||
<TableRow key={`${record.id}-raw-${key}`}>
|
||||
<TableCell scope="row" className={classes.tableCell}>
|
||||
{key}:
|
||||
</TableCell>
|
||||
<TableCell align="left" className={classes.value}>
|
||||
{value.join(' • ')}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
</TableContainer>
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -43,6 +43,7 @@ const defaultConfig = {
|
||||
separator: '/',
|
||||
enableInspect: true,
|
||||
pluginsEnabled: true,
|
||||
enableTagEditing: false,
|
||||
}
|
||||
|
||||
let config
|
||||
|
||||
@ -52,6 +52,9 @@
|
||||
"playNext": "Play Next",
|
||||
"info": "Get Info",
|
||||
"instantMix": "Instant Mix"
|
||||
},
|
||||
"notifications": {
|
||||
"updated": "Song updated"
|
||||
}
|
||||
},
|
||||
"album": {
|
||||
|
||||
25
ui/src/song/SongEditButton.jsx
Normal file
25
ui/src/song/SongEditButton.jsx
Normal file
@ -0,0 +1,25 @@
|
||||
import React from 'react'
|
||||
import { IconButton, Tooltip } from '@material-ui/core'
|
||||
import EditIcon from '@material-ui/icons/Edit'
|
||||
import { useTranslate } from 'react-admin'
|
||||
import { useSongEditor } from './SongEditorContext'
|
||||
|
||||
export const SongEditButton = ({ record }) => {
|
||||
const { openEditor } = useSongEditor()
|
||||
const translate = useTranslate()
|
||||
|
||||
const handleClick = (e) => {
|
||||
e.stopPropagation()
|
||||
openEditor(record)
|
||||
}
|
||||
|
||||
return (
|
||||
<Tooltip title={translate('ra.action.edit', { _: 'Edit' })}>
|
||||
<IconButton size="small" onClick={handleClick}>
|
||||
<EditIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
export default SongEditButton
|
||||
193
ui/src/song/SongEditor.jsx
Normal file
193
ui/src/song/SongEditor.jsx
Normal file
@ -0,0 +1,193 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react'
|
||||
import {
|
||||
useGetOne,
|
||||
useNotify,
|
||||
useRefresh,
|
||||
useTranslate,
|
||||
} from 'react-admin'
|
||||
import {
|
||||
Button,
|
||||
Dialog,
|
||||
DialogActions,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
TextField,
|
||||
CircularProgress,
|
||||
} from '@material-ui/core'
|
||||
import httpClient from '../dataProvider/httpClient'
|
||||
|
||||
export const SongEditor = ({ songId, song: initialSong, onClose }) => {
|
||||
const [song, setSong] = useState(initialSong || null)
|
||||
const [formData, setFormData] = useState({
|
||||
title: '',
|
||||
artist: '',
|
||||
album: '',
|
||||
year: '',
|
||||
genre: '',
|
||||
trackNumber: '',
|
||||
})
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
const notify = useNotify()
|
||||
const translate = useTranslate()
|
||||
const refresh = useRefresh()
|
||||
|
||||
const { data: fetchedSong, loading } = useGetOne(
|
||||
'song',
|
||||
songId,
|
||||
{ enabled: !!songId && !initialSong }
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
const source = initialSong || fetchedSong
|
||||
if (source) {
|
||||
setSong(source)
|
||||
setFormData({
|
||||
title: source.title || '',
|
||||
artist: source.artist || '',
|
||||
album: source.album || '',
|
||||
year: source.year || '',
|
||||
genre: source.genre || '',
|
||||
trackNumber: source.trackNumber || '',
|
||||
})
|
||||
}
|
||||
}, [initialSong, fetchedSong])
|
||||
|
||||
const handleChange = useCallback((field) => (event) => {
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
[field]: event.target.value,
|
||||
}))
|
||||
}, [])
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
if (!song) return
|
||||
|
||||
setIsSaving(true)
|
||||
const payload = {
|
||||
title: formData.title,
|
||||
artist: formData.artist,
|
||||
album: formData.album,
|
||||
year: formData.year ? parseInt(formData.year, 10) : null,
|
||||
genre: formData.genre,
|
||||
trackNumber: formData.trackNumber ? parseInt(formData.trackNumber, 10) : null,
|
||||
}
|
||||
|
||||
try {
|
||||
await httpClient(`/api/v1/song/${song.id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
notify('resources.song.notifications.updated', 'info', { smart_count: 1 })
|
||||
refresh()
|
||||
if (onClose) {
|
||||
onClose()
|
||||
}
|
||||
} catch (error) {
|
||||
notify('ra.notification.updated', { type: 'warning' })
|
||||
} finally {
|
||||
setIsSaving(false)
|
||||
}
|
||||
}, [song, formData, notify, refresh, onClose])
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
if (!isSaving && onClose) {
|
||||
onClose()
|
||||
}
|
||||
}, [isSaving, onClose])
|
||||
|
||||
const isOpen = !!song
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={isOpen}
|
||||
onClose={handleClose}
|
||||
aria-labelledby="song-editor-dialog"
|
||||
fullWidth={true}
|
||||
maxWidth={'sm'}
|
||||
>
|
||||
<DialogTitle id="song-editor-dialog">
|
||||
{translate('resources.song.actions.edit', { _: 'Edit Song' })}
|
||||
</DialogTitle>
|
||||
<DialogContent>
|
||||
{loading ? (
|
||||
<CircularProgress />
|
||||
) : (
|
||||
<>
|
||||
<TextField
|
||||
value={formData.title}
|
||||
onChange={handleChange('title')}
|
||||
autoFocus
|
||||
fullWidth
|
||||
variant={'outlined'}
|
||||
label={translate('resources.song.fields.title', { _: 'Title' })}
|
||||
disabled={isSaving}
|
||||
margin="normal"
|
||||
/>
|
||||
<TextField
|
||||
value={formData.artist}
|
||||
onChange={handleChange('artist')}
|
||||
fullWidth
|
||||
variant={'outlined'}
|
||||
label={translate('resources.song.fields.artist', { _: 'Artist' })}
|
||||
disabled={isSaving}
|
||||
margin="normal"
|
||||
/>
|
||||
<TextField
|
||||
value={formData.album}
|
||||
onChange={handleChange('album')}
|
||||
fullWidth
|
||||
variant={'outlined'}
|
||||
label={translate('resources.song.fields.album', { _: 'Album' })}
|
||||
disabled={isSaving}
|
||||
margin="normal"
|
||||
/>
|
||||
<TextField
|
||||
value={formData.year}
|
||||
onChange={handleChange('year')}
|
||||
fullWidth
|
||||
variant={'outlined'}
|
||||
label={translate('resources.song.fields.year', { _: 'Year' })}
|
||||
disabled={isSaving}
|
||||
margin="normal"
|
||||
type="number"
|
||||
/>
|
||||
<TextField
|
||||
value={formData.genre}
|
||||
onChange={handleChange('genre')}
|
||||
fullWidth
|
||||
variant={'outlined'}
|
||||
label={translate('resources.song.fields.genre', { _: 'Genre' })}
|
||||
disabled={isSaving}
|
||||
margin="normal"
|
||||
/>
|
||||
<TextField
|
||||
value={formData.trackNumber}
|
||||
onChange={handleChange('trackNumber')}
|
||||
fullWidth
|
||||
variant={'outlined'}
|
||||
label={translate('resources.song.fields.trackNumber', { _: 'Track #' })}
|
||||
disabled={isSaving}
|
||||
margin="normal"
|
||||
type="number"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={handleClose} color="primary" disabled={isSaving}>
|
||||
{translate('ra.action.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
color="primary"
|
||||
disabled={isSaving}
|
||||
startIcon={isSaving ? <CircularProgress size={20} /> : null}
|
||||
>
|
||||
{translate('ra.action.save')}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
export default SongEditor
|
||||
37
ui/src/song/SongEditorContext.jsx
Normal file
37
ui/src/song/SongEditorContext.jsx
Normal file
@ -0,0 +1,37 @@
|
||||
import { useState, useCallback, createContext, useContext } from 'react'
|
||||
|
||||
const SongEditorContext = createContext(null)
|
||||
|
||||
export const useSongEditor = () => {
|
||||
const context = useContext(SongEditorContext)
|
||||
if (!context) {
|
||||
throw new Error('useSongEditor must be used within SongEditorProvider')
|
||||
}
|
||||
return context
|
||||
}
|
||||
|
||||
export const SongEditorProvider = ({ children }) => {
|
||||
const [songId, setSongId] = useState(null)
|
||||
const [song, setSong] = useState(null)
|
||||
|
||||
const openEditor = useCallback((idOrSong) => {
|
||||
if (typeof idOrSong === 'object') {
|
||||
setSong(idOrSong)
|
||||
setSongId(idOrSong.id)
|
||||
} else {
|
||||
setSongId(idOrSong)
|
||||
setSong(null)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const closeEditor = useCallback(() => {
|
||||
setSongId(null)
|
||||
setSong(null)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<SongEditorContext.Provider value={{ songId, song, openEditor, closeEditor }}>
|
||||
{children}
|
||||
</SongEditorContext.Provider>
|
||||
)
|
||||
}
|
||||
170
ui/src/song/SongEditorDialog.jsx
Normal file
170
ui/src/song/SongEditorDialog.jsx
Normal file
@ -0,0 +1,170 @@
|
||||
import React from 'react'
|
||||
import { useState, useCallback } from 'react'
|
||||
import {
|
||||
Edit,
|
||||
SimpleForm,
|
||||
TextInput,
|
||||
useNotify,
|
||||
useRefresh,
|
||||
useRedirect,
|
||||
useMutation,
|
||||
} from 'react-admin'
|
||||
import {
|
||||
Dialog,
|
||||
DialogActions,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
Button,
|
||||
TextField,
|
||||
CircularProgress,
|
||||
} from '@material-ui/core'
|
||||
import httpClient from '../dataProvider/httpClient'
|
||||
|
||||
export const SongEditorDialog = ({ songId, onClose }) => {
|
||||
const [formData, setFormData] = useState({
|
||||
title: '',
|
||||
artist: '',
|
||||
album: '',
|
||||
year: '',
|
||||
genre: '',
|
||||
trackNumber: '',
|
||||
})
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
const [initialLoading, setInitialLoading] = useState(true)
|
||||
|
||||
const notify = useNotify()
|
||||
const refresh = useRefresh()
|
||||
|
||||
useMutation(
|
||||
{
|
||||
type: 'getOne',
|
||||
resource: 'song',
|
||||
payload: { id: songId },
|
||||
},
|
||||
{
|
||||
onSuccess: (data) => {
|
||||
setFormData({
|
||||
title: data?.title || '',
|
||||
artist: data?.artist || '',
|
||||
album: data?.album || '',
|
||||
year: data?.year || '',
|
||||
genre: data?.genre || '',
|
||||
trackNumber: data?.trackNumber || '',
|
||||
})
|
||||
setInitialLoading(false)
|
||||
},
|
||||
onError: () => {
|
||||
setInitialLoading(false)
|
||||
notify('ra.notification.item_not_found', 'warning')
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
useCallback((field) => (event) => {
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
[field]: event.target.value,
|
||||
}))
|
||||
}, [])
|
||||
|
||||
const handleSave = async () => {
|
||||
setIsSaving(true)
|
||||
const payload = {
|
||||
title: formData.title,
|
||||
artist: formData.artist,
|
||||
album: formData.album,
|
||||
year: formData.year ? parseInt(formData.year, 10) : null,
|
||||
genre: formData.genre,
|
||||
trackNumber: formData.trackNumber ? parseInt(formData.trackNumber, 10) : null,
|
||||
}
|
||||
|
||||
try {
|
||||
await httpClient(`/api/v1/song/${songId}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
notify('resources.song.notifications.updated', 'info', { smart_count: 1 })
|
||||
refresh()
|
||||
if (onClose) onClose()
|
||||
} catch (error) {
|
||||
notify('ra.notification.updated', { type: 'warning' })
|
||||
} finally {
|
||||
setIsSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={!!songId} onClose={onClose} fullWidth maxWidth="sm">
|
||||
<DialogTitle>Edit Song</DialogTitle>
|
||||
<DialogContent>
|
||||
{initialLoading ? (
|
||||
<CircularProgress />
|
||||
) : (
|
||||
<>
|
||||
<TextField
|
||||
value={formData.title}
|
||||
onChange={(e) => setFormData({ ...formData, title: e.target.value })}
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
label="Title"
|
||||
margin="normal"
|
||||
/>
|
||||
<TextField
|
||||
value={formData.artist}
|
||||
onChange={(e) => setFormData({ ...formData, artist: e.target.value })}
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
label="Artist"
|
||||
margin="normal"
|
||||
/>
|
||||
<TextField
|
||||
value={formData.album}
|
||||
onChange={(e) => setFormData({ ...formData, album: e.target.value })}
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
label="Album"
|
||||
margin="normal"
|
||||
/>
|
||||
<TextField
|
||||
value={formData.year}
|
||||
onChange={(e) => setFormData({ ...formData, year: e.target.value })}
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
label="Year"
|
||||
margin="normal"
|
||||
type="number"
|
||||
/>
|
||||
<TextField
|
||||
value={formData.genre}
|
||||
onChange={(e) => setFormData({ ...formData, genre: e.target.value })}
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
label="Genre"
|
||||
margin="normal"
|
||||
/>
|
||||
<TextField
|
||||
value={formData.trackNumber}
|
||||
onChange={(e) => setFormData({ ...formData, trackNumber: e.target.value })}
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
label="Track #"
|
||||
margin="normal"
|
||||
type="number"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={onClose} disabled={isSaving}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleSave} disabled={isSaving} startIcon={isSaving ? <CircularProgress size={20} /> : null}>
|
||||
Save
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
export default SongEditorDialog
|
||||
Loading…
x
Reference in New Issue
Block a user